diff --git a/.editorconfig b/.editorconfig index 5e7700ca..fc58240a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -102,6 +102,8 @@ csharp_style_expression_bodied_operators = true:suggestion csharp_style_expression_bodied_properties = true:suggestion csharp_style_expression_bodied_indexers = true:suggestion csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = true:suggestion # Pattern matching preferences csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion csharp_style_pattern_matching_over_as_with_null_check = true:suggestion diff --git a/CommandLineToolExample/Program.cs b/CommandLineToolExample/Program.cs index 2c8f6347..10d988d8 100644 --- a/CommandLineToolExample/Program.cs +++ b/CommandLineToolExample/Program.cs @@ -44,8 +44,6 @@ public static void Main(string[] args) { } private class ConsoleProgressReport : IProgress<(string, string)> { - public void Report((string, string) value) { - Console.WriteLine(value.Item1 + " - " + value.Item2); - } + public void Report((string, string) value) => Console.WriteLine(value.Item1 + " - " + value.Item2); } } diff --git a/Docs/CodeStyle.md b/Docs/CodeStyle.md index bd6dac74..7e922a5e 100644 --- a/Docs/CodeStyle.md +++ b/Docs/CodeStyle.md @@ -18,7 +18,6 @@ The main idea is to keep the code maintainable and readable. ### Code * In the programmers' haven, there is always a free spot for those who write tests. -* Please try to keep the lines shorter than 190 characters so they fit on most monitors. * If you add a TODO, then please describe the details in the commit message, or ideally in a github issue. That increases the chances of the TODOs being addressed. #### Difference with C# conventions @@ -52,3 +51,24 @@ private void One( // An empty line to clearly separate the definition and the start of the function } ``` + +### Line wrap +Please try to keep the lines shorter than 190 characters, so they fit on most monitors. + +When wrapping a line, you can use the following example as a guideline for which wrapping is preferred: +``` +if (recipe.subgroup == null + // (1) Add breaks at operators before adding them within the expressions passed to those operators + && imgui.BuildRedButton("Delete recipe") + // (2) Add breaks before .MethodName before adding breaks between method parameters + .WithTooltip(imgui, + // (3) Add breaks between method parameters before adding breaks within method parameters + "Shortcut: right-click") + // (1) + && imgui.CloseDropdown()) { +``` + +Most of the operators like `=>` `.` `+` `&&` go to the next line. +The notable operators that stay on the same line are `=> {` and `,`. + +The wrapping of arguments in constructors and method-definitions is up to you. diff --git a/Yafc.Model.Tests/Analysis/Milestones.cs b/Yafc.Model.Tests/Analysis/Milestones.cs index a7307653..d579cb69 100644 --- a/Yafc.Model.Tests/Analysis/Milestones.cs +++ b/Yafc.Model.Tests/Analysis/Milestones.cs @@ -3,6 +3,7 @@ #pragma warning disable CA1861 // "CA1861: Avoid constant arrays as arguments." Disabled because it tried to fix constant arrays in InlineData. namespace Yafc.Model.Tests; + public class MilestonesTests { private static Bits createBits(ulong value) { var bitsType = typeof(Bits); @@ -23,7 +24,6 @@ private static Milestones setupMilestones(ulong result, ulong mask, out Factorio [factorioObj] = createBits(result) }; - var milestonesType = typeof(Milestones); var milestonesLockedMask = milestonesType.GetProperty("lockedMask"); var milestoneResultField = milestonesType.GetField("milestoneResult", BindingFlags.NonPublic | BindingFlags.Instance); @@ -72,8 +72,8 @@ public void GetLockedMaskFromProject_ShouldCalculateMask(bool unlocked, int[] bi var projectField = milestonesType.GetField("project", BindingFlags.NonPublic | BindingFlags.Instance); var milestones = setupMilestones(0, 0, out FactorioObject factorioObj); - Project project = new Project(); + if (unlocked) { // Can't use SetFlag() as it uses the Undo system, which requires SDL var flags = project.settings.itemFlags; @@ -87,11 +87,12 @@ public void GetLockedMaskFromProject_ShouldCalculateMask(bool unlocked, int[] bi _ = getLockedMaskFromProject.Invoke(milestones, null); var lockedBits = milestones.lockedMask; - int index = 0; + for (int i = 0; i < lockedBits.length; i++) { bool expectSet = index == bitsCleared.Length || bitsCleared[index] != i; Assert.True(expectSet == lockedBits[i], "bit " + i + " is expected to be " + (expectSet ? "set" : "cleared")); + if (index < bitsCleared.Length && bitsCleared[index] == i) { index++; } diff --git a/Yafc.Model.Tests/Data/DataUtils.cs b/Yafc.Model.Tests/Data/DataUtils.cs index bed3dc99..0be9da15 100644 --- a/Yafc.Model.Tests/Data/DataUtils.cs +++ b/Yafc.Model.Tests/Data/DataUtils.cs @@ -20,6 +20,7 @@ public void TryParseAmount_IsInverseOfFormatValue() { for (UnitOfMeasure unit = 0; unit < UnitOfMeasure.Celsius; unit++) { float value; int count = 1; + do { r.NextBytes(bytes); value = BitConverter.ToSingle(bytes, 0); @@ -92,7 +93,9 @@ public void TryParseAmount_IsInverseOfFormatValue_WithBeltsAndPipes() { [Theory] [MemberData(nameof(TryParseAmount_TestData))] - public void TryParseAmount_WhenGivenInputs_ShouldProduceCorrectValues(string input, UnitOfMeasure unitOfMeasure, bool expectedReturn, float expectedOutput, int time, int itemUnit, int fluidUnit, int defaultBeltSpeed) { + public void TryParseAmount_WhenGivenInputs_ShouldProduceCorrectValues(string input, UnitOfMeasure unitOfMeasure, bool expectedReturn, float expectedOutput, int time, + int itemUnit, int fluidUnit, int defaultBeltSpeed) { + Project.current.preferences.time = time; Project.current.preferences.itemUnit = itemUnit; Project.current.preferences.fluidUnit = fluidUnit; @@ -100,6 +103,7 @@ public void TryParseAmount_WhenGivenInputs_ShouldProduceCorrectValues(string inp typeof(EntityBelt).GetProperty(nameof(EntityBelt.beltItemsPerSecond)).SetValue(Project.current.preferences.defaultBelt, defaultBeltSpeed); Assert.Equal(expectedReturn, DataUtils.TryParseAmount(input, out float result, unitOfMeasure)); + if (expectedReturn) { double error = (result - expectedOutput) / (double)expectedOutput; Assert.True(Math.Abs(error) < .00001, $"Parsing {input} produced {result}, which differs from the expected {expectedOutput} by {error:0.00%}."); diff --git a/Yafc.Model.Tests/LuaDependentTestHelper.cs b/Yafc.Model.Tests/LuaDependentTestHelper.cs index ddef3e6b..71287fdb 100644 --- a/Yafc.Model.Tests/LuaDependentTestHelper.cs +++ b/Yafc.Model.Tests/LuaDependentTestHelper.cs @@ -35,10 +35,13 @@ static LuaDependentTestHelper() { internal static Project GetProjectForLua(string targetStreamName = null) { // Verify correct non-parallel declaration for tests, to accomodate the singleton analyses. StackTrace stack = new(); + for (int i = 1; i < stack.FrameCount; i++) { + // Search up the stack until we find a method with [Fact] or [Theory]. MethodBase method = stack.GetFrame(i).GetMethod(); if (method.GetCustomAttribute() != null || method.GetCustomAttribute() != null) { + targetStreamName ??= method.DeclaringType.FullName + '.' + method.Name + ".lua"; // CollectionAttribute doesn't store its constructor argument, so we have to read the attribute data instead of the constructed attribute. @@ -48,6 +51,7 @@ internal static Project GetProjectForLua(string targetStreamName = null) { // A second test can replace the analysis results while the first is still running. Assert.Fail($"Test classes that call {nameof(LuaDependentTestHelper)}.{nameof(GetProjectForLua)} must be annotated with [Collection(\"LuaDependentTests\")]."); } + break; } } diff --git a/Yafc.Model.Tests/Math/Bits.cs b/Yafc.Model.Tests/Math/Bits.cs index caaf8c99..f1086823 100644 --- a/Yafc.Model.Tests/Math/Bits.cs +++ b/Yafc.Model.Tests/Math/Bits.cs @@ -1,7 +1,10 @@ using System.Reflection; using Xunit; +#pragma warning disable CA1861 // "CA1861: Avoid constant arrays as arguments." Disabled because it tried to fix constant arrays in InlineData. + namespace Yafc.Model.Tests; + public class BitsTests { [Fact] public void New_WhenTrueIsProvided_ShouldHaveBit0Set() { @@ -20,6 +23,7 @@ public void SetBit_WhenGivenABit_ShouldReturnSetBit(int bit) { bits[bit] = true; Assert.True(bits[bit], "Set bit should be set"); + for (int i = 0; i <= 128; i++) { if (i != bit) { Assert.False(bits[i], "Other bits should be clear"); @@ -354,7 +358,6 @@ public void UnequalOperator_WithSameValueDifferentLengths_ShouldReturnCorrectRes Assert.False(a != b); } - [Fact] public void UnequalOperator_WithDefault_ShouldReturnFalse() { Bits a = default; @@ -374,14 +377,12 @@ public void SubtractOperator_WithLongInputBits_ShouldReturnCorrectResult() { var result = a - 1; - ulong[] resultValue = (ulong[])bitsData.GetValue(result); Assert.Equal(~0ul, resultValue[0]); Assert.Equal(2ul, resultValue[1]); } - [Theory] [InlineData(1, 1)] [InlineData(2, 1)] diff --git a/Yafc.Model.Tests/Model/ProductionTableContentTests.cs b/Yafc.Model.Tests/Model/ProductionTableContentTests.cs index 27780005..b2cb8505 100644 --- a/Yafc.Model.Tests/Model/ProductionTableContentTests.cs +++ b/Yafc.Model.Tests/Model/ProductionTableContentTests.cs @@ -29,11 +29,17 @@ public void ChangeFuelEntityModules_ShouldPreserveFixedAmount() { static void testCombinations(RecipeRow row, ProductionTable table, Action assert) { foreach (EntityCrafter crafter in Database.allCrafters) { row.entity = crafter; + foreach (Goods fuel in crafter.energy.fuels) { row.fuel = fuel; + foreach (Module module in Database.allModules.Concat([null])) { ModuleTemplateBuilder builder = new(); - if (module != null) { builder.list.Add((module, 0)); } + + if (module != null) { + builder.list.Add((module, 0)); + } + row.modules = builder.Build(row); table.Solve((ProjectPage)table.owner).Wait(); assert(); @@ -65,15 +71,17 @@ public void ChangeProductionTableModuleConfig_ShouldPreserveFixedAmount() { void testCombinations(RecipeRow row, ProductionTable table, Action assert) { foreach (EntityCrafter crafter in Database.allCrafters) { row.entity = crafter; + foreach (Goods fuel in crafter.energy.fuels) { row.fuel = fuel; + foreach (Module module in modules) { for (int beaconCount = 0; beaconCount < 13; beaconCount++) { for (float payback = 1; payback < float.MaxValue; payback *= 16) { if (table.GetType().GetProperty("modules").SetMethod is MethodInfo method) { // Pre-emptive code for if ProductionTable.modules is made writable. // The ProductionTable.modules setter must notify all relevant recipes if it is added. - method.Invoke(table, [new ModuleFillerParameters(table) { + _ = method.Invoke(table, [new ModuleFillerParameters(table) { beacon = beacon, beaconModule = module, beaconsPerBuilding = beaconCount, diff --git a/Yafc.Model/Analysis/Analysis.cs b/Yafc.Model/Analysis/Analysis.cs index 56dd8f08..e677e0c2 100644 --- a/Yafc.Model/Analysis/Analysis.cs +++ b/Yafc.Model/Analysis/Analysis.cs @@ -3,16 +3,16 @@ using System.Linq; namespace Yafc.Model; + public abstract class Analysis { internal readonly HashSet excludedObjects = []; public abstract void Compute(Project project, ErrorCollector warnings); private static readonly List analyses = []; - public static void RegisterAnalysis(Analysis analysis, params Analysis[] dependencies) // TODO don't ignore dependencies - { - analyses.Add(analysis); - } + + // TODO don't ignore dependencies + public static void RegisterAnalysis(Analysis analysis, params Analysis[] dependencies) => analyses.Add(analysis); public static void ProcessAnalyses(IProgress<(string, string)> progress, Project project, ErrorCollector errors) { foreach (var analysis in analyses) { @@ -44,41 +44,23 @@ public static void ExcludeFromAnalysis(FactorioObject obj) where T : Analysis } public static class AnalysisExtensions { - public static bool IsAccessible(this FactorioObject obj) { - return Milestones.Instance.GetMilestoneResult(obj) != 0; - } + public static bool IsAccessible(this FactorioObject obj) => Milestones.Instance.GetMilestoneResult(obj) != 0; - public static bool IsAccessibleWithCurrentMilestones(this FactorioObject obj) { - return Milestones.Instance.IsAccessibleWithCurrentMilestones(obj); - } + public static bool IsAccessibleWithCurrentMilestones(this FactorioObject obj) => Milestones.Instance.IsAccessibleWithCurrentMilestones(obj); - public static bool IsAutomatable(this FactorioObject obj) { - return AutomationAnalysis.Instance.automatable[obj] != AutomationStatus.NotAutomatable; - } + public static bool IsAutomatable(this FactorioObject obj) => AutomationAnalysis.Instance.automatable[obj] != AutomationStatus.NotAutomatable; - public static bool IsAutomatableWithCurrentMilestones(this FactorioObject obj) { - return AutomationAnalysis.Instance.automatable[obj] == AutomationStatus.AutomatableNow; - } + public static bool IsAutomatableWithCurrentMilestones(this FactorioObject obj) => AutomationAnalysis.Instance.automatable[obj] == AutomationStatus.AutomatableNow; - public static float Cost(this FactorioObject goods, bool atCurrentMilestones = false) { - return CostAnalysis.Get(atCurrentMilestones).cost[goods]; - } + public static float Cost(this FactorioObject goods, bool atCurrentMilestones = false) => CostAnalysis.Get(atCurrentMilestones).cost[goods]; - public static float ApproximateFlow(this FactorioObject recipe, bool atCurrentMilestones = false) { - return CostAnalysis.Get(atCurrentMilestones).flow[recipe]; - } + public static float ApproximateFlow(this FactorioObject recipe, bool atCurrentMilestones = false) => CostAnalysis.Get(atCurrentMilestones).flow[recipe]; - public static float ProductCost(this Recipe recipe, bool atCurrentMilestones = false) { - return CostAnalysis.Get(atCurrentMilestones).recipeProductCost[recipe]; - } + public static float ProductCost(this Recipe recipe, bool atCurrentMilestones = false) => CostAnalysis.Get(atCurrentMilestones).recipeProductCost[recipe]; - public static float RecipeWaste(this Recipe recipe, bool atCurrentMilestones = false) { - return CostAnalysis.Get(atCurrentMilestones).recipeWastePercentage[recipe]; - } + public static float RecipeWaste(this Recipe recipe, bool atCurrentMilestones = false) => CostAnalysis.Get(atCurrentMilestones).recipeWastePercentage[recipe]; - public static float RecipeBaseCost(this Recipe recipe, bool atCurrentMilestones = false) { - return CostAnalysis.Get(atCurrentMilestones).recipeCost[recipe]; - } + public static float RecipeBaseCost(this Recipe recipe, bool atCurrentMilestones = false) => CostAnalysis.Get(atCurrentMilestones).recipeCost[recipe]; /// /// Filters a list of s down to those that were not excluded by the specified diff --git a/Yafc.Model/Analysis/AutomationAnalysis.cs b/Yafc.Model/Analysis/AutomationAnalysis.cs index c0ac04dc..ff30d455 100644 --- a/Yafc.Model/Analysis/AutomationAnalysis.cs +++ b/Yafc.Model/Analysis/AutomationAnalysis.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc.Model; + public enum AutomationStatus : sbyte { NotAutomatable = -1, AutomatableLater = 2, AutomatableNow = 3, } @@ -22,8 +23,10 @@ public override void Compute(Project project, ErrorCollector warnings) { state[Database.voidEnergy] = AutomationStatus.AutomatableNow; Queue processingQueue = new Queue(Database.objects.count); int unknowns = 0; + foreach (Recipe recipe in Database.recipes.all.ExceptExcluded(this)) { bool hasAutomatableCrafter = false; + foreach (var crafter in recipe.crafters) { if (crafter != Database.character && crafter.IsAccessible()) { hasAutomatableCrafter = true; @@ -49,6 +52,7 @@ public override void Compute(Project project, ErrorCollector warnings) { var index = processingQueue.Dequeue(); var dependencies = Dependencies.dependencyList[index]; var automationState = Milestones.Instance.IsAccessibleWithCurrentMilestones(index) ? AutomationStatus.AutomatableNow : AutomationStatus.AutomatableLater; + foreach (var depGroup in dependencies) { if (!depGroup.flags.HasFlags(DependencyList.Flags.OneTimeInvestment)) { if (depGroup.flags.HasFlags(DependencyList.Flags.RequireEverything)) { @@ -60,6 +64,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } else { var localHighest = AutomationStatus.NotAutomatable; + foreach (var element in depGroup.elements) { if (state[element] > localHighest) { localHighest = state[element]; @@ -74,6 +79,7 @@ public override void Compute(Project project, ErrorCollector warnings) { else if (automationState == AutomationStatus.AutomatableNow && depGroup.flags == DependencyList.Flags.CraftingEntity) { // If only character is accessible at current milestones as a crafting entity, don't count the object as currently automatable bool hasMachine = false; + foreach (var element in depGroup.elements) { if (element != Database.character?.id && Milestones.Instance.IsAccessibleWithCurrentMilestones(element)) { hasMachine = true; @@ -94,8 +100,10 @@ public override void Compute(Project project, ErrorCollector warnings) { state[index] = automationState; if (automationState != Unknown) { unknowns--; + foreach (var revDep in Dependencies.reverseDependencies[index]) { var oldState = state[revDep]; + if (oldState == Unknown || (oldState == AutomationStatus.AutomatableLater && automationState == AutomationStatus.AutomatableNow)) { if (oldState == AutomationStatus.AutomatableLater) { unknowns++; diff --git a/Yafc.Model/Analysis/CostAnalysis.cs b/Yafc.Model/Analysis/CostAnalysis.cs index af479f28..b3e29f23 100644 --- a/Yafc.Model/Analysis/CostAnalysis.cs +++ b/Yafc.Model/Analysis/CostAnalysis.cs @@ -8,14 +8,13 @@ using Yafc.UI; namespace Yafc.Model; + public class CostAnalysis(bool onlyCurrentMilestones) : Analysis { private readonly ILogger logger = Logging.GetLogger(); public static readonly CostAnalysis Instance = new CostAnalysis(false); public static readonly CostAnalysis InstanceAtMilestones = new CostAnalysis(true); - public static CostAnalysis Get(bool atCurrentMilestones) { - return atCurrentMilestones ? InstanceAtMilestones : Instance; - } + public static CostAnalysis Get(bool atCurrentMilestones) => atCurrentMilestones ? InstanceAtMilestones : Instance; private const float CostPerSecond = 0.1f; private const float CostPerMj = 0.1f; @@ -39,9 +38,7 @@ public static CostAnalysis Get(bool atCurrentMilestones) { private readonly bool onlyCurrentMilestones = onlyCurrentMilestones; private string? itemAmountPrefix; - private bool ShouldInclude(FactorioObject obj) { - return onlyCurrentMilestones ? obj.IsAutomatableWithCurrentMilestones() : obj.IsAutomatable(); - } + private bool ShouldInclude(FactorioObject obj) => onlyCurrentMilestones ? obj.IsAutomatableWithCurrentMilestones() : obj.IsAutomatable(); public override void Compute(Project project, ErrorCollector warnings) { var workspaceSolver = DataUtils.CreateSolver(); @@ -55,12 +52,14 @@ public override void Compute(Project project, ErrorCollector warnings) { Dictionary sciencePackUsage = []; if (!onlyCurrentMilestones && project.preferences.targetTechnology != null) { itemAmountPrefix = "Estimated amount for " + project.preferences.targetTechnology.locName + ": "; + foreach (var spUsage in TechnologyScienceAnalysis.Instance.allSciencePacks[project.preferences.targetTechnology]) { sciencePackUsage[spUsage.goods] = spUsage.amount; } } else { itemAmountPrefix = "Estimated amount for all researches: "; + foreach (Technology technology in Database.technologies.all.ExceptExcluded(this)) { if (technology.IsAccessible() && technology.ingredients is not null) { foreach (var ingredient in technology.ingredients) { @@ -77,13 +76,13 @@ public override void Compute(Project project, ErrorCollector warnings) { } } - foreach (Goods goods in Database.goods.all.ExceptExcluded(this)) { if (!ShouldInclude(goods)) { continue; } float mapGeneratedAmount = 0f; + foreach (var src in goods.miscSources) { if (src is Entity ent && ent.mapGenerated) { foreach (var product in ent.loot) { @@ -93,6 +92,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } } } + var variable = workspaceSolver.MakeVar(CostLowerLimit, CostLimitWhenGeneratesOnMap / mapGeneratedAmount, false, goods.name); objective.SetCoefficient(variable, 1e-3); // adding small amount to each object cost, so even objects that aren't required for science will get cost calculated variables[goods] = variable; @@ -107,6 +107,7 @@ public override void Compute(Project project, ErrorCollector warnings) { recipeCost = Database.recipes.CreateMapping(); flow = Database.objects.CreateMapping(); var lastVariable = Database.goods.CreateMapping(); + foreach (Recipe recipe in Database.recipes.all.ExceptExcluded(this)) { if (!ShouldInclude(recipe)) { continue; @@ -122,8 +123,10 @@ public override void Compute(Project project, ErrorCollector warnings) { float minEmissions = 100f; int minSize = 15; float minPower = 1000f; + foreach (var crafter in recipe.crafters) { minEmissions = MathF.Min(crafter.energy.emissions, minEmissions); + if (crafter.energy.type == EntityEnergyType.Heat) { break; } @@ -133,6 +136,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } float power = crafter.energy.type == EntityEnergyType.Void ? 0f : recipe.time * crafter.power / (crafter.craftingSpeed * crafter.energy.effectivity); + if (power < minPower) { minPower = power; } @@ -146,7 +150,9 @@ public override void Compute(Project project, ErrorCollector warnings) { singleUsedFuel = null; break; } + float amount = power / fuel.fuelValue; + if (singleUsedFuel == null) { singleUsedFuel = fuel; singleUsedFuelAmount = amount; @@ -183,6 +189,7 @@ public override void Compute(Project project, ErrorCollector warnings) { 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; } @@ -199,6 +206,7 @@ public override void Compute(Project project, ErrorCollector warnings) { 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; } @@ -209,12 +217,14 @@ public override void Compute(Project project, ErrorCollector warnings) { 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); @@ -248,6 +258,7 @@ public override void Compute(Project project, ErrorCollector warnings) { // TODO this is temporary fix for fluid temperatures (make the cost of fluid with lower temp not higher than the cost of fluid with higher temp) foreach (var (name, fluids) in Database.fluidVariants) { var prev = fluids[0]; + for (int i = 1; i < fluids.Count; i++) { var cur = fluids[i]; var constraint = workspaceSolver.MakeConstraint(double.NegativeInfinity, 0, "fluid-" + name + "-" + prev.temperature); @@ -261,6 +272,7 @@ public override void Compute(Project project, ErrorCollector warnings) { logger.Information("Cost analysis completed in {ElapsedTime}ms with result {result}", time.ElapsedMilliseconds, result); float sumImportance = 1f; int totalRecipes = 0; + if (result is Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE) { float objectiveValue = (float)objective.Value(); logger.Information("Estimated modpack cost: {EstimatedCost}", DataUtils.FormatAmount(objectiveValue * 1000f, UnitOfMeasure.None)); @@ -279,6 +291,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } float recipeFlow = (float)constraints[recipe].DualValue(); + if (recipeFlow > 0f) { totalRecipes++; sumImportance += recipeFlow; @@ -307,6 +320,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } else if (o is Entity entity) { float minimal = float.PositiveInfinity; + foreach (var item in entity.itemsToPlace) { if (export[item] < minimal) { minimal = export[item]; @@ -326,6 +340,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } float productCost = 0f; + foreach (var product in recipe.products) { productCost += product.amount * export[product.goods]; } @@ -339,13 +354,15 @@ public override void Compute(Project project, ErrorCollector warnings) { } } - importantItems = [.. Database.goods.all.ExceptExcluded(this).Where(x => x.usages.Length > 1).OrderByDescending(x => flow[x] * cost[x] * x.usages.Count(y => ShouldInclude(y) && recipeWastePercentage[y] == 0f))]; + importantItems = [.. Database.goods.all.ExceptExcluded(this).Where(x => x.usages.Length > 1) + .OrderByDescending(x => flow[x] * cost[x] * x.usages.Count(y => ShouldInclude(y) && recipeWastePercentage[y] == 0f))]; workspaceSolver.Dispose(); } - public override string description => "Cost analysis computes a hypothetical late-game base. This simulation has two very important results: How much does stuff (items, recipes, etc) cost and how much of stuff do you need. " + - "It also collects a bunch of auxiliary results, for example how efficient are different recipes. These results are used as heuristics and weights for calculations, and are also useful by themselves."; + public override string description => "Cost analysis computes a hypothetical late-game base. This simulation has two very important results: " + + "How much does stuff (items, recipes, etc) cost and how much of stuff do you need. It also collects a bunch of auxiliary results, for example " + + "how efficient are different recipes. These results are used as heuristics and weights for calculations, and are also useful by themselves."; private static readonly StringBuilder sb = new StringBuilder(); public static string GetDisplayCost(FactorioObject goods) { @@ -360,6 +377,7 @@ public static string GetDisplayCost(FactorioObject goods) { float compareCost = cost; float compareCostNow = costNow; string costPrefix; + if (goods is Fluid) { compareCost = cost * 50; compareCostNow = costNow * 50; @@ -379,6 +397,7 @@ public static string GetDisplayCost(FactorioObject goods) { } _ = sb.Append(costPrefix).Append(" ¥").Append(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); + if (compareCostNow > compareCost && !float.IsPositiveInfinity(compareCostNow)) { _ = sb.Append(" (Currently ¥").Append(DataUtils.FormatAmount(compareCostNow, UnitOfMeasure.None)).Append(')'); } @@ -386,9 +405,7 @@ public static string GetDisplayCost(FactorioObject goods) { return sb.ToString(); } - public static float GetBuildingHours(Recipe recipe, float flow) { - return recipe.time * flow * (1000f / 3600f); - } + public static float GetBuildingHours(Recipe recipe, float flow) => recipe.time * flow * (1000f / 3600f); public string? GetItemAmount(Goods goods) { float itemFlow = flow[goods]; diff --git a/Yafc.Model/Analysis/Dependencies.cs b/Yafc.Model/Analysis/Dependencies.cs index 344c0444..e843e146 100644 --- a/Yafc.Model/Analysis/Dependencies.cs +++ b/Yafc.Model/Analysis/Dependencies.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; namespace Yafc.Model; + public interface IDependencyCollector { void Add(FactorioId[] raw, DependencyList.Flags flags); void Add(IReadOnlyList raw, DependencyList.Flags flags); @@ -36,12 +37,14 @@ public static class Dependencies { public static void Calculate() { dependencyList = Database.objects.CreateMapping(); reverseDependencies = Database.objects.CreateMapping>(); + foreach (var obj in Database.objects.all) { reverseDependencies[obj] = []; } DependencyCollector collector = new DependencyCollector(); List temp = []; + foreach (var obj in Database.objects.all) { obj.GetDependencies(collector, temp); var packed = collector.Pack(); @@ -60,12 +63,11 @@ public static void Calculate() { private class DependencyCollector : IDependencyCollector { private readonly List list = []; - public void Add(FactorioId[] raw, DependencyList.Flags flags) { - list.Add(new DependencyList { elements = raw, flags = flags }); - } + public void Add(FactorioId[] raw, DependencyList.Flags flags) => list.Add(new DependencyList { elements = raw, flags = flags }); public void Add(IReadOnlyList raw, DependencyList.Flags flags) { FactorioId[] elems = new FactorioId[raw.Count]; + for (int i = 0; i < raw.Count; i++) { elems[i] = raw[i].id; } @@ -76,6 +78,7 @@ public void Add(IReadOnlyList raw, DependencyList.Flags flags) { public DependencyList[] Pack() { var packed = list.ToArray(); list.Clear(); + return packed; } } diff --git a/Yafc.Model/Analysis/Milestones.cs b/Yafc.Model/Analysis/Milestones.cs index 408ce65b..04a35f26 100644 --- a/Yafc.Model/Analysis/Milestones.cs +++ b/Yafc.Model/Analysis/Milestones.cs @@ -6,6 +6,7 @@ using Yafc.UI; namespace Yafc.Model; + public class Milestones : Analysis { public static readonly Milestones Instance = new Milestones(); @@ -16,13 +17,9 @@ public class Milestones : Analysis { public Bits lockedMask { get; private set; } = new(); private Project? project; - public bool IsAccessibleWithCurrentMilestones(FactorioId obj) { - return (milestoneResult[obj] & lockedMask) == 1; - } + public bool IsAccessibleWithCurrentMilestones(FactorioId obj) => (milestoneResult[obj] & lockedMask) == 1; - public bool IsAccessibleWithCurrentMilestones(FactorioObject obj) { - return (milestoneResult[obj] & lockedMask) == 1; - } + public bool IsAccessibleWithCurrentMilestones(FactorioObject obj) => (milestoneResult[obj] & lockedMask) == 1; public bool IsAccessibleAtNextMilestone(FactorioObject obj) { var milestoneMask = milestoneResult[obj] & lockedMask; @@ -33,20 +30,20 @@ public bool IsAccessibleAtNextMilestone(FactorioObject obj) { if (milestoneMask[0]) { return false; } - // TODO Always returns false -> milestoneMask is a power of 2 + 1 always has bit 0 set, as x pow 2 sets one (high) bit, so the + 1 adds bit 0, which is detected by (milestoneMask & 1) != 0 + // TODO Always returns false -> milestoneMask is a power of 2 + 1 always has bit 0 set, as x pow 2 sets one (high) bit, + // so the + 1 adds bit 0, which is detected by (milestoneMask & 1) != 0 // return ((milestoneMask - 1) & (milestoneMask - 2)) == 0; // milestoneMask is a power of 2 + 1 return false; } + /// + /// Return a copy of Bits + /// + public Bits GetMilestoneResult(FactorioId obj) => new Bits(milestoneResult[obj]); - public Bits GetMilestoneResult(FactorioId obj) { - // Return a copy of Bits - return new Bits(milestoneResult[obj]); - } - - public Bits GetMilestoneResult(FactorioObject obj) { - // Return a copy of Bits - return new Bits(milestoneResult[obj]); - } + /// + /// Return a copy of Bits + /// + public Bits GetMilestoneResult(FactorioObject obj) => new Bits(milestoneResult[obj]); private void GetLockedMaskFromProject() { if (project is null) { @@ -83,6 +80,7 @@ private void ProjectSettingsChanged(bool visualOnly) { } int msb = ms.HighestBitSet() - 1; + return msb < 0 || msb >= currentMilestones.Length ? null : currentMilestones[msb]; } @@ -99,7 +97,7 @@ public override void Compute(Project project, ErrorCollector warnings) { ComputeWithParameters(project, warnings, Database.allSciencePacks, true); } else { - ComputeWithParameters(project, warnings, project.settings.milestones.ToArray(), false); + ComputeWithParameters(project, warnings, [.. project.settings.milestones], false); } } @@ -141,6 +139,7 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact } else { currentMilestones = milestones; + for (int i = 0; i < milestones.Length; i++) { // result[milestones[i]] = (1ul << (i + 1)) | 1; Bits b = new Bits(true); @@ -161,10 +160,13 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact Bits flagMask = new Bits(); for (int i = 0; i <= currentMilestones.Length; i++) { flagMask[i] = true; + if (i > 0) { var milestone = currentMilestones[i - 1]; + if (milestone == null) { milestonesNotReachable = []; + foreach (var pack in Database.allSciencePacks) { if (Array.IndexOf(currentMilestones, pack) == -1) { currentMilestones[nextMilestoneIndex++] = pack; @@ -172,6 +174,7 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact } } Array.Resize(ref currentMilestones, nextMilestoneIndex); + break; } logger.Information("Processing milestone {Milestone}", milestone.locName); @@ -183,7 +186,6 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact var elem = processingQueue.Dequeue(); var entry = dependencyList[elem]; - var cur = result[elem]; var elementFlags = cur; bool isInitial = (processing[elem] & ProcessingFlags.Initial) != 0; @@ -193,6 +195,7 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact if ((list.flags & DependencyList.Flags.RequireEverything) != 0) { foreach (var req in list.elements) { var reqFlags = result[req]; + if (reqFlags.IsClear() && !isInitial) { goto skip; } @@ -202,8 +205,10 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact } else { Bits groupFlags = new Bits(); + foreach (var req in list.elements) { var acc = result[req]; + if (acc.IsClear()) { continue; } @@ -241,6 +246,7 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact } result[elem] = elementFlags; + foreach (var reverseDependency in reverseDependencies[elem]) { if ((processing[reverseDependency] & ~ProcessingFlags.MilestoneNeedOrdering) != 0 || !result[reverseDependency].IsClear()) { continue; @@ -263,24 +269,28 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact bool hasAutomatableRocketLaunch = result[Database.objectsByTypeName["Special.launch"]] != 0; if (accessibleObjects < Database.objects.count / 2) { - warnings.Error("More than 50% of all in-game objects appear to be inaccessible in this project with your current mod list. This can have a variety of reasons like objects being accessible via scripts," + - MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); + warnings.Error("More than 50% of all in-game objects appear to be inaccessible in this project with your current mod list. This can have a variety of reasons like objects " + + "being accessible via scripts," + MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); } else if (!hasAutomatableRocketLaunch) { warnings.Error("Rocket launch appear to be inaccessible. This means that rocket may not be launched in this mod pack, or it requires mod script to spawn or unlock some items," + - MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); + MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); } else if (milestonesNotReachable != null) { - warnings.Error("There are some milestones that are not accessible: " + string.Join(", ", milestonesNotReachable.Select(x => x.locName)) + ". You may remove these from milestone list," + - MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); + warnings.Error("There are some milestones that are not accessible: " + string.Join(", ", milestonesNotReachable.Select(x => x.locName)) + + ". You may remove these from milestone list," + MaybeBug + MilestoneAnalysisIsImportant + UseDependencyExplorer, ErrorSeverity.AnalysisWarning); } + logger.Information("Milestones calculation finished in {ElapsedTime}ms.", time.ElapsedMilliseconds); milestoneResult = result; } private const string MaybeBug = " or it might be due to a bug inside a mod or YAFC."; private const string MilestoneAnalysisIsImportant = "\nA lot of YAFC's systems rely on objects being accessible, so some features may not work as intended."; - private const string UseDependencyExplorer = "\n\nFor this reason YAFC has a Dependency Explorer that allows you to manually enable some of the core recipes. YAFC will iteratively try to unlock all the dependencies after each recipe you manually enabled. For most modpacks it's enough to unlock a few early recipes like any special recipes for plates that everything in the mod is based on."; + private const string UseDependencyExplorer = "\n\nFor this reason YAFC has a Dependency Explorer that allows you to manually enable some of the core recipes. " + + "YAFC will iteratively try to unlock all the dependencies after each recipe you manually enabled. " + + "For most modpacks it's enough to unlock a few early recipes like any special recipes for plates that everything in the mod is based on."; - public override string description => "Milestone analysis starts from objects that are placed on map by the map generator and tries to find all objects that are accessible from that, taking notes about which objects are locked behind which milestones."; + public override string description => "Milestone analysis starts from objects that are placed on map by the map generator and tries to find all objects that are accessible from that, " + + "taking notes about which objects are locked behind which milestones."; } diff --git a/Yafc.Model/Analysis/TechnologyLoopsFinder.cs b/Yafc.Model/Analysis/TechnologyLoopsFinder.cs index 0c557edf..13fedfa8 100644 --- a/Yafc.Model/Analysis/TechnologyLoopsFinder.cs +++ b/Yafc.Model/Analysis/TechnologyLoopsFinder.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc.Model; + public static class TechnologyLoopsFinder { private static readonly ILogger logger = Logging.GetLogger(typeof(TechnologyLoopsFinder)); diff --git a/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs b/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs index ba7c994f..5b34f46d 100644 --- a/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs +++ b/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs @@ -2,6 +2,7 @@ using System.Linq; namespace Yafc.Model; + public class TechnologyScienceAnalysis : Analysis { public static readonly TechnologyScienceAnalysis Instance = new TechnologyScienceAnalysis(); public Mapping allSciencePacks { get; private set; } @@ -12,6 +13,7 @@ public class TechnologyScienceAnalysis : Analysis { Bits order = new Bits(); foreach (Ingredient entry in list) { Bits entryOrder = Milestones.Instance.GetMilestoneResult(entry.goods.id); + if (entryOrder != 0) { entryOrder -= 1; }// else: The science pack is not accessible *and* not a milestone. We may still display it, but any actual milestone will win. @@ -28,25 +30,28 @@ public class TechnologyScienceAnalysis : Analysis { public override void Compute(Project project, ErrorCollector warnings) { var sciencePacks = Database.allSciencePacks; var sciencePackIndex = Database.goods.CreateMapping(); + for (int i = 0; i < sciencePacks.Length; i++) { sciencePackIndex[sciencePacks[i]] = i; } Mapping[] sciencePackCount = new Mapping[sciencePacks.Length]; + for (int i = 0; i < sciencePacks.Length; i++) { sciencePackCount[i] = Database.technologies.CreateMapping(); } var processing = Database.technologies.CreateMapping(); var requirementMap = Database.technologies.CreateMapping(Database.technologies); - Queue queue = new Queue(); + foreach (Technology tech in Database.technologies.all.ExceptExcluded(this)) { if (tech.prerequisites.Length == 0) { processing[tech] = true; queue.Enqueue(tech); } } + Queue prerequisiteQueue = new Queue(); while (queue.Count > 0) { @@ -55,6 +60,7 @@ public override void Compute(Project project, ErrorCollector warnings) { // Fast processing for the first prerequisite (just copy everything) if (current.prerequisites.Length > 0) { var firstRequirement = current.prerequisites[0]; + foreach (var pack in sciencePackCount) { pack[current] += pack[firstRequirement]; } @@ -67,6 +73,7 @@ public override void Compute(Project project, ErrorCollector warnings) { while (prerequisiteQueue.Count > 0) { var prerequisite = prerequisiteQueue.Dequeue(); + foreach (var ingredient in prerequisite.ingredients) { int science = sciencePackIndex[ingredient.goods]; sciencePackCount[science][current] += ingredient.amount * prerequisite.count; @@ -96,7 +103,8 @@ public override void Compute(Project project, ErrorCollector warnings) { } } - allSciencePacks = Database.technologies.CreateMapping(tech => sciencePackCount.Select((x, id) => x[tech] == 0 ? null : new Ingredient(sciencePacks[id], x[tech])).WhereNotNull().ToArray()); + allSciencePacks = Database.technologies.CreateMapping( + tech => sciencePackCount.Select((x, id) => x[tech] == 0 ? null : new Ingredient(sciencePacks[id], x[tech])).WhereNotNull().ToArray()); } public override string description => diff --git a/Yafc.Model/Blueprints/Blueprint.cs b/Yafc.Model/Blueprints/Blueprint.cs index 75ee5420..bc002c3e 100644 --- a/Yafc.Model/Blueprints/Blueprint.cs +++ b/Yafc.Model/Blueprints/Blueprint.cs @@ -8,17 +8,19 @@ using Yafc.UI; namespace Yafc.Blueprints; + [Serializable] public class BlueprintString(string blueprintName) { public Blueprint blueprint { get; } = new Blueprint(blueprintName); - private static readonly byte[] header = { 0x78, 0xDA }; + private static readonly byte[] header = [0x78, 0xDA]; + private static readonly JsonSerializerOptions jsonSerializerOptions = new() { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }; public string ToBpString() { if (InputSystem.Instance.control) { return ToJson(); } - byte[] sourceBytes = JsonSerializer.SerializeToUtf8Bytes(this, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); + byte[] sourceBytes = JsonSerializer.SerializeToUtf8Bytes(this, jsonSerializerOptions); using MemoryStream memory = new MemoryStream(); memory.Write(header); using (DeflateStream compress = new DeflateStream(memory, CompressionLevel.Optimal, true)) { @@ -26,25 +28,30 @@ public string ToBpString() { } memory.Write(GetChecksum(sourceBytes, sourceBytes.Length)); + return "0" + Convert.ToBase64String(memory.ToArray()); } - private byte[] GetChecksum(byte[] buffer, int length) { + private static byte[] GetChecksum(byte[] buffer, int length) { int a = 1, b = 0; + for (int counter = 0; counter < length; ++counter) { a = (a + buffer[counter]) % 65521; b = (b + a) % 65521; } + int checksum = (b * 65536) + a; byte[] intBytes = BitConverter.GetBytes(checksum); Array.Reverse(intBytes); + return intBytes; } public string ToJson() { - byte[] sourceBytes = JsonSerializer.SerializeToUtf8Bytes(this, new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull }); + byte[] sourceBytes = JsonSerializer.SerializeToUtf8Bytes(this, jsonSerializerOptions); using MemoryStream memory = new MemoryStream(sourceBytes); using StreamReader reader = new StreamReader(memory); + return reader.ReadToEnd(); } } diff --git a/Yafc.Model/Blueprints/BlueprintUtilities.cs b/Yafc.Model/Blueprints/BlueprintUtilities.cs index 733aa1f6..cd9f506d 100644 --- a/Yafc.Model/Blueprints/BlueprintUtilities.cs +++ b/Yafc.Model/Blueprints/BlueprintUtilities.cs @@ -4,9 +4,11 @@ using Yafc.Model; namespace Yafc.Blueprints; + public static class BlueprintUtilities { private static string ExportBlueprint(BlueprintString blueprint, bool copyToClipboard) { string result = blueprint.ToBpString(); + if (copyToClipboard) { _ = SDL.SDL_SetClipboardText(result); } @@ -20,15 +22,18 @@ public static string ExportConstantCombinators(string name, IReadOnlyList<(Goods BlueprintString blueprint = new BlueprintString(name); int index = 0; BlueprintEntity? last = null; + for (int i = 0; i < combinatorCount; i++) { BlueprintControlBehavior controlBehavior = new BlueprintControlBehavior(); BlueprintEntity entity = new BlueprintEntity { index = i + 1, position = { x = i + offset, y = 0 }, name = "constant-combinator", controlBehavior = controlBehavior }; blueprint.blueprint.entities.Add(entity); + for (int j = 0; j < Database.constantCombinatorCapacity; j++) { var (item, amount) = goods[index++]; BlueprintControlFilter filter = new BlueprintControlFilter { index = j + 1, count = amount }; filter.signal.Set(item); controlBehavior.filters.Add(filter); + if (index >= goods.Count) { break; } @@ -53,13 +58,16 @@ public static string ExportRequesterChests(string name, IReadOnlyList<(Item item int offset = -chest.size * combinatorCount / 2; BlueprintString blueprint = new BlueprintString(name); int index = 0; + for (int i = 0; i < combinatorCount; i++) { BlueprintEntity entity = new BlueprintEntity { index = i + 1, position = { x = (i * chest.size) + offset, y = 0 }, name = chest.name }; blueprint.blueprint.entities.Add(entity); + for (int j = 0; j < chest.logisticSlotsCount; j++) { var (item, amount) = goods[index++]; BlueprintRequestFilter filter = new BlueprintRequestFilter { index = j + 1, count = amount, name = item.name }; entity.requestFilters.Add(filter); + if (index >= goods.Count) { break; } diff --git a/Yafc.Model/Data/DataClasses.cs b/Yafc.Model/Data/DataClasses.cs index 1e8d29dd..cf2479d2 100644 --- a/Yafc.Model/Data/DataClasses.cs +++ b/Yafc.Model/Data/DataClasses.cs @@ -7,6 +7,7 @@ [assembly: InternalsVisibleTo("Yafc.Parser")] namespace Yafc.Model; + public interface IFactorioObjectWrapper { string text { get; } FactorioObject target { get; } @@ -60,13 +61,9 @@ public void FallbackLocalization(FactorioObject? other, string description) { public abstract void GetDependencies(IDependencyCollector collector, List temp); - public override string ToString() { - return name; - } + public override string ToString() => name; - public int CompareTo(FactorioObject? other) { - return DataUtils.DefaultOrdering.Compare(this, other); - } + public int CompareTo(FactorioObject? other) => DataUtils.DefaultOrdering.Compare(this, other); public virtual bool showInExplorers => true; } @@ -77,9 +74,7 @@ public class FactorioIconPart(string path) { public float x, y, r = 1, g = 1, b = 1, a = 1; public float scale = 1; - public bool IsSimple() { - return x == 0 && y == 0 && r == 1 && g == 1 && b == 1 && a == 1 && scale == 1; - } + public bool IsSimple() => x == 0 && y == 0 && r == 1 && g == 1 && b == 1 && a == 1 && scale == 1; } [Flags] @@ -169,6 +164,7 @@ public bool HasIngredientVariants() { public override void GetDependencies(IDependencyCollector collector, List temp) { base.GetDependencies(collector, temp); + if (!enabled) { collector.Add(technologyUnlock, DependencyList.Flags.TechnologyUnlock); } @@ -184,9 +180,7 @@ public bool IsProductivityAllowed() { return false; } - public override bool CanAcceptModule(Item module) { - return modules.Contains(module); - } + public override bool CanAcceptModule(Item module) => modules.Contains(module); } public class Mechanics : Recipe { @@ -251,6 +245,7 @@ public class Product : IFactorioObjectWrapper { public void SetCatalyst(float catalyst) { float catalyticMin = amountMin - catalyst; float catalyticMax = amountMax - catalyst; + if (catalyticMax <= 0) { productivityAmount = 0f; } @@ -287,8 +282,10 @@ public Product(Goods goods, float min, float max, float probability) { string IFactorioObjectWrapper.text { get { string text = goods.locName; + if (amountMin != 1f || amountMax != 1f) { text = DataUtils.FormatAmount(amountMax, UnitOfMeasure.None) + "x " + text; + if (amountMin != amountMax) { text = DataUtils.FormatAmount(amountMin, UnitOfMeasure.None) + "-" + text; } @@ -314,9 +311,7 @@ public abstract class Goods : FactorioObject { public Entity[] fuelFor { get; internal set; } = []; public abstract UnitOfMeasure flowUnitOfMeasure { get; } - public override void GetDependencies(IDependencyCollector collector, List temp) { - collector.Add(production.Concat(miscSources).ToArray(), DependencyList.Flags.Source); - } + public override void GetDependencies(IDependencyCollector collector, List temp) => collector.Add(production.Concat(miscSources).ToArray(), DependencyList.Flags.Source); public virtual bool HasSpentFuel([MaybeNullWhen(false)] out Item spent) { spent = null; @@ -454,9 +449,7 @@ public static bool CanAcceptModule(ModuleSpecification module, AllowedEffects ef return true; } - public bool CanAcceptModule(ModuleSpecification module) { - return CanAcceptModule(module, allowedEffects); - } + public bool CanAcceptModule(ModuleSpecification module) => CanAcceptModule(module, allowedEffects); } public class EntityCrafter : EntityWithModules { @@ -554,13 +547,9 @@ public struct TemperatureRange(int min, int max) { public int max = max; public static readonly TemperatureRange Any = new TemperatureRange(int.MinValue, int.MaxValue); - public readonly bool IsAny() { - return min == int.MinValue && max == int.MaxValue; - } + public readonly bool IsAny() => min == int.MinValue && max == int.MaxValue; - public readonly bool IsSingle() { - return min == max; - } + public readonly bool IsSingle() => min == max; public TemperatureRange(int single) : this(single, single) { } @@ -572,7 +561,5 @@ public override readonly string ToString() { return min + "°-" + max + "°"; } - public readonly bool Contains(int value) { - return min <= value && max >= value; - } + public readonly bool Contains(int value) => min <= value && max >= value; } diff --git a/Yafc.Model/Data/DataUtils.cs b/Yafc.Model/Data/DataUtils.cs index d8ab9aab..bf59b067 100644 --- a/Yafc.Model/Data/DataUtils.cs +++ b/Yafc.Model/Data/DataUtils.cs @@ -11,21 +11,25 @@ using Yafc.UI; namespace Yafc.Model; + public static partial class DataUtils { private static readonly ILogger logger = Logging.GetLogger(typeof(DataUtils)); public static readonly FactorioObjectComparer DefaultOrdering = new FactorioObjectComparer((x, y) => { float yFlow = y.ApproximateFlow(); float xFlow = x.ApproximateFlow(); + if (xFlow != yFlow) { return xFlow.CompareTo(yFlow); } Recipe? rx = x as Recipe; Recipe? ry = y as Recipe; + if (rx != null || ry != null) { float xWaste = rx?.RecipeWaste() ?? 0; float yWaste = ry?.RecipeWaste() ?? 0; + return xWaste.CompareTo(yWaste); } @@ -44,6 +48,7 @@ public static partial class DataUtils { public static readonly FactorioObjectComparer DefaultRecipeOrdering = new FactorioObjectComparer((x, y) => { float yFlow = y.ApproximateFlow(); float xFlow = x.ApproximateFlow(); + if (yFlow != xFlow) { return yFlow > xFlow ? 1 : -1; } @@ -72,6 +77,7 @@ public static partial class DataUtils { public static Bits GetMilestoneOrder(FactorioId id) { var ms = Milestones.Instance; + if (ms.GetMilestoneResult(id).IsClear()) { // subtracting 1 of all zeros would set all bits ANDing this with lockedMask is equal to lockedMask return ms.lockedMask; @@ -82,6 +88,7 @@ public static Bits GetMilestoneOrder(FactorioId id) { public static string dataPath { get; internal set; } = ""; public static string modsPath { get; internal set; } = ""; public static bool expensiveRecipes { get; internal set; } + /// /// If , recipe selection windows will only display recipes that provide net production or consumption of the in question. /// If , recipe selection windows will show all recipes that produce or consume any quantity of that .
@@ -96,7 +103,8 @@ public static Bits GetMilestoneOrder(FactorioId id) { public static readonly Random random = new Random(); /// - /// Call to get the favorite or only useful item in the list, considering milestones, accessibility, and , provided there is exactly one such item. + /// Call to get the favorite or only useful item in the list, considering milestones, accessibility, and , + /// provided there is exactly one such item. /// If no best item exists, returns . Always returns a tooltip applicable to using ctrl+click to add a recipe. /// /// The element type of . This type must be derived from . @@ -104,7 +112,8 @@ public static Bits GetMilestoneOrder(FactorioId id) { /// Upon return, contains a hint that is applicable to using ctrl+click to add a recipe. /// This will either suggest using ctrl+click, or explain why ctrl+click cannot be used. /// It is not useful when is not . - /// Items that are not accessible at the current milestones are always ignored. After those have been discarded, the return value is the first applicable entry in the following list: + /// Items that are not accessible at the current milestones are always ignored. After those have been discarded, + /// the return value is the first applicable entry in the following list: /// /// The only normal item in . /// The only normal user favorite in . @@ -119,12 +128,14 @@ public static Bits GetMilestoneOrder(FactorioId id) { HashSet userFavorites = Project.current.preferences.favorites; bool acceptOnlyFavorites = false; T? element = null; + if (list.Any(t => t.IsAccessible())) { recipeHint = "Hint: Complete milestones to enable ctrl+click"; } else { recipeHint = "Hint: Mark a recipe as accessible to enable ctrl+click"; } + foreach (T elem in list) { // Always consider normal entries. A list with two normals and one special should select nothing, rather than selecting the only special item. if (!elem.IsAccessibleWithCurrentMilestones() || (elem.specialType != FactorioObjectSpecialType.Normal && excludeSpecial)) { @@ -139,6 +150,7 @@ public static Bits GetMilestoneOrder(FactorioId id) { } else { recipeHint = "Hint: Cannot ctrl+click with multiple favorited recipes"; + return null; } } @@ -166,7 +178,8 @@ public static void SetupForProject(Project project) { } private class FactorioObjectDeterministicComparer : IComparer { - public int Compare(FactorioObject? x, FactorioObject? y) => Comparer.Default.Compare((int?)x?.id, (int?)y?.id); // id comparison is deterministic because objects are sorted deterministically + // id comparison is deterministic because objects are sorted deterministically + public int Compare(FactorioObject? x, FactorioObject? y) => Comparer.Default.Compare((int?)x?.id, (int?)y?.id); } private class FluidTemperatureComparerImp : IComparer { @@ -191,6 +204,7 @@ public int Compare(T? x, T? y) { var msx = GetMilestoneOrder(x.id); var msy = GetMilestoneOrder(y.id); + if (msx != msy) { return msx.CompareTo(msy); } @@ -213,11 +227,13 @@ public static Solver.ResultStatus TrySolveWithDifferentSeeds(this Solver solver) Stopwatch time = Stopwatch.StartNew(); var result = solver.Solve(); logger.Information("Solution completed in {ElapsedTime}ms with result {result}", time.ElapsedMilliseconds, result); + if (result == Solver.ResultStatus.ABNORMAL) { _ = solver.SetSolverSpecificParametersAsString("random_seed:" + random.Next()); continue; } /*else VerySlowTryFindBadObjective(solver);*/ + return result; } return Solver.ResultStatus.ABNORMAL; @@ -227,11 +243,14 @@ public static void VerySlowTryFindBadObjective(Solver solver) { var vars = solver.variables(); var obj = solver.Objective(); logger.Information(solver.ExportModelAsLpFormat(false)); + foreach (var v in vars) { obj.SetCoefficient(v, 0); var result = solver.Solve(); + if (result == Solver.ResultStatus.OPTIMAL) { logger.Warning("Infeasibility candidate: {candidate}", v.Name()); + return; } } @@ -242,6 +261,7 @@ public static bool RemoveValue(this Dictionary dict, foreach (var (k, v) in dict) { if (comparer.Equals(v, value)) { _ = dict.Remove(k); + return true; } } @@ -280,12 +300,14 @@ public int Compare(T? x, T? y) { bool hasX = userFavorites.Contains(x); bool hasY = userFavorites.Contains(y); + if (hasX != hasY) { return hasY.CompareTo(hasX); } _ = bumps.TryGetValue(x, out int ix); _ = bumps.TryGetValue(y, out int iy); + if (ix == iy) { return def.Compare(x, y); } @@ -296,6 +318,7 @@ public int Compare(T? x, T? y) { public static float GetProductionPerRecipe(this RecipeOrTechnology recipe, Goods product) { float amount = 0f; + foreach (var p in recipe.products) { if (p.goods == product) { amount += p.amount; @@ -306,6 +329,7 @@ public static float GetProductionPerRecipe(this RecipeOrTechnology recipe, Goods public static float GetProductionForRow(this RecipeRow row, Goods product) { float amount = 0f; + foreach (var p in row.recipe.products) { if (p.goods == product) { amount += p.GetAmountForRow(row); @@ -316,6 +340,7 @@ public static float GetProductionForRow(this RecipeRow row, Goods product) { public static float GetConsumptionPerRecipe(this RecipeOrTechnology recipe, Goods product) { float amount = 0f; + foreach (var ingredient in recipe.ingredients) { if (ingredient.ContainsVariant(product)) { amount += ingredient.amount; @@ -326,6 +351,7 @@ public static float GetConsumptionPerRecipe(this RecipeOrTechnology recipe, Good public static float GetConsumptionForRow(this RecipeRow row, Goods ingredient) { float amount = 0f; + foreach (var i in row.recipe.ingredients) { if (i.ContainsVariant(ingredient)) { amount += i.amount * (float)row.recipesPerSecond; @@ -345,8 +371,10 @@ public static float GetConsumptionForRow(this RecipeRow row, Goods ingredient) { comparer = Comparer.Default; } } + bool first = true; T? best = default; + foreach (var elem in list) { if (first || comparer.Compare(best, elem) > 0) { first = false; @@ -373,13 +401,16 @@ public static float GetConsumptionForRow(this RecipeRow row, Goods ingredient) { if (throwIfMultiple) { return values.SingleOrDefault(predicate); } + bool found = false; T? foundItem = default; + foreach (T item in values) { if (predicate?.Invoke(item) ?? true) { // defend against null here to allow the other overload to pass null, rather than re-implementing the loop. if (found) { return default; } + found = true; foundItem = item; } @@ -389,6 +420,7 @@ public static float GetConsumptionForRow(this RecipeRow row, Goods ingredient) { public static void MoveListElementIndex(this IList list, int from, int to) { var moving = list[from]; + if (from > to) { for (int i = from - 1; i >= to; i--) { list[i + 1] = list[i]; @@ -470,6 +502,7 @@ public static readonly (char suffix, float multiplier, string format)[] PreciseF private static readonly StringBuilder amountBuilder = new StringBuilder(); public static bool HasFlags(this T enumeration, T flags) where T : unmanaged, Enum { int target = Unsafe.As(ref flags); + return (Unsafe.As(ref enumeration) & target) == target; } @@ -477,6 +510,7 @@ public static bool HasFlags(this T enumeration, T flags) where T : unmanaged, public static string FormatTime(float time) { _ = amountBuilder.Clear(); + if (time < 10f) { return $"{time:#.#} seconds"; } @@ -502,6 +536,7 @@ public static string FormatTime(float time) { public static string FormatAmount(float amount, UnitOfMeasure unit, string? prefix = null, string? suffix = null, bool precise = false) { var (multiplier, unitSuffix) = Project.current == null ? (1f, null) : Project.current.ResolveUnitOfMeasure(unit); + return FormatAmountRaw(amount, multiplier, unitSuffix, precise ? PreciseFormat : FormatSpec, prefix, suffix); } @@ -528,11 +563,13 @@ public static string FormatAmountRaw(float amount, float unitMultiplier, string? int idx = MathUtils.Clamp(MathUtils.Floor(MathF.Log10(amount)) + 8, 0, formatSpec.Length - 1); var val = formatSpec[idx]; _ = amountBuilder.Append((amount * val.multiplier).ToString(val.format)); + if (val.suffix != NO) { _ = amountBuilder.Append(val.suffix); } _ = amountBuilder.Append(unitSuffix); + if (suffix != null) { _ = amountBuilder.Append(suffix); } @@ -564,6 +601,7 @@ public static bool TryParseAmount(string str, out float amount, UnitOfMeasure un str = str.Replace(" ", ""); // Remove spaces to support parsing from the "10 000" precise format, and to simplify the regex. var groups = ParseAmountRegex().Match(str).Groups; amount = 0; + if (groups.Count < 4 || !float.TryParse(groups[1].Value, out amount)) { return false; } @@ -639,8 +677,10 @@ public static bool TryParseAmount(string str, out float amount, UnitOfMeasure un (mul, _) = Project.current.preferences.GetPerTimeUnit(); break; } + multiplier /= mul; amount *= multiplier; + return amount is <= 1e15f and >= -1e15f; } @@ -655,12 +695,14 @@ public static void WriteException(this TextWriter writer, Exception ex) { } int nextPosition = Array.IndexOf(buffer, (byte)'\n', position); + if (nextPosition == -1) { nextPosition = buffer.Length; } string str = Encoding.UTF8.GetString(buffer, position, nextPosition - position); position = nextPosition + 1; + return str; } @@ -674,10 +716,11 @@ public static bool Match(this FactorioObject? obj, SearchQuery query) { } foreach (string token in query.tokens) { - if (obj.name.IndexOf(token, StringComparison.OrdinalIgnoreCase) < 0 && - obj.locName.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0 && - (obj.locDescr == null || obj.locDescr.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0) && - (obj.factorioType == null || obj.factorioType.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0)) { + if (obj.name.IndexOf(token, StringComparison.OrdinalIgnoreCase) < 0 + && obj.locName.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0 + && (obj.locDescr == null || obj.locDescr.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0) + && (obj.factorioType == null || obj.factorioType.IndexOf(token, StringComparison.InvariantCultureIgnoreCase) < 0)) { + return false; } } diff --git a/Yafc.Model/Data/Database.cs b/Yafc.Model/Data/Database.cs index 051f22ea..25c15532 100644 --- a/Yafc.Model/Data/Database.cs +++ b/Yafc.Model/Data/Database.cs @@ -5,6 +5,7 @@ using System.Linq; namespace Yafc.Model; + public static class Database { // null-forgiveness for all static properties here: public static FactorioObject[] rootAccessible { get; internal set; } = null!; @@ -44,7 +45,8 @@ public static class Database { /// /// Fetches a module that can be used in this beacon, or if no beacon was specified or no module could be found. /// - /// The beacon to receive a module. If , will be set to null and this method will return . + /// The beacon to receive a module. If , will be set to null and this + /// method will return . /// A module that can be placed in that beacon, if such a module exists. /// if a module could be found, or if the supplied beacon does not accept any modules or was . public static bool GetDefaultModuleFor(EntityBeacon? beacon, [NotNullWhen(true)] out Module? module) { @@ -107,13 +109,9 @@ public FactorioIdRange(int start, int end, List source) { public T this[int i] => all[i]; public T this[FactorioId id] => all[(int)id - start]; - public Mapping CreateMapping() { - return new Mapping(this); - } + public Mapping CreateMapping() => new Mapping(this); - public Mapping CreateMapping(FactorioIdRange other) where TOther : FactorioObject { - return new Mapping(this, other); - } + public Mapping CreateMapping(FactorioIdRange other) where TOther : FactorioObject => new Mapping(this, other); public Mapping CreateMapping(Func mapFunc) { var map = CreateMapping(); @@ -125,22 +123,17 @@ public Mapping CreateMapping(Func mapFunc) { } } -// Mapping[TKey, TValue] is almost like a dictionary where TKey is FactorioObject but it is an array wrapper and therefore very fast. This is preferable way to add custom properties to FactorioObjects +// Mapping[TKey, TValue] is almost like a dictionary where TKey is FactorioObject but it is an array wrapper and therefore very fast. +// This is preferable way to add custom properties to FactorioObjects public readonly struct Mapping(FactorioIdRange source) : IDictionary where TKey : FactorioObject { private readonly int offset = source.start; private readonly FactorioIdRange source = source; - public void Add(TKey key, TValue value) { - this[key] = value; - } + public void Add(TKey key, TValue value) => this[key] = value; - public bool ContainsKey(TKey key) { - return true; - } + public bool ContainsKey(TKey key) => true; - public bool Remove(TKey key) { - throw new NotSupportedException(); - } + public bool Remove(TKey key) => throw new NotSupportedException(); public bool TryGetValue(TKey key, out TValue value) { value = this[key]; @@ -164,13 +157,9 @@ public Mapping Remap(Func remap) { public ref TValue this[TKey index] => ref Values[(int)index.id - offset]; public ref TValue this[FactorioId id] => ref Values[(int)(id - offset)]; //public ref TValue this[int id] => ref data[id]; - public void Clear() { - Array.Clear(Values, 0, Values.Length); - } + public void Clear() => Array.Clear(Values, 0, Values.Length); - public bool Remove(KeyValuePair item) { - return Remove(item.Key); - } + public bool Remove(KeyValuePair item) => Remove(item.Key); public int Count => Values.Length; public bool IsReadOnly => false; @@ -178,25 +167,15 @@ public bool Remove(KeyValuePair item) { public TValue[] Values { get; } = new TValue[source.count]; ICollection IDictionary.Keys => Keys; ICollection IDictionary.Values => Values; - IEnumerator> IEnumerable>.GetEnumerator() { - return GetEnumerator(); - } + IEnumerator> IEnumerable>.GetEnumerator() => GetEnumerator(); - public Enumerator GetEnumerator() { - return new Enumerator(this); - } + public Enumerator GetEnumerator() => new Enumerator(this); - IEnumerator IEnumerable.GetEnumerator() { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - public void Add(KeyValuePair item) { - this[item.Key] = item.Value; - } + public void Add(KeyValuePair item) => this[item.Key] = item.Value; - public bool Contains(KeyValuePair item) { - return EqualityComparer.Default.Equals(this[item.Key], item.Value); - } + public bool Contains(KeyValuePair item) => EqualityComparer.Default.Equals(this[item.Key], item.Value); public void CopyTo(KeyValuePair[] array, int arrayIndex) { for (int i = 0; i < Values.Length; i++) { @@ -213,13 +192,9 @@ internal Enumerator(Mapping mapping) { keys = mapping.source.all; values = mapping.Values; } - public bool MoveNext() { - return ++index < keys.Length; - } + public bool MoveNext() => ++index < keys.Length; - public void Reset() { - index = -1; - } + public void Reset() => index = -1; public readonly KeyValuePair Current => new KeyValuePair(keys[index], values[index]); readonly object IEnumerator.Current => Current; @@ -248,11 +223,7 @@ public void CopyRow(TKey1 from, TKey1 to) { Array.Copy(data, fromId, data, toId, count1); } - public ArraySegment GetSlice(TKey1 row) { - return new ArraySegment(data, ((int)row.id - offset1) * count1, count1); - } + public ArraySegment GetSlice(TKey1 row) => new ArraySegment(data, ((int)row.id - offset1) * count1, count1); - public FactorioId IndexToId(int index) { - return (FactorioId)(index + offset2); - } + public FactorioId IndexToId(int index) => (FactorioId)(index + offset2); } diff --git a/Yafc.Model/Data/PageReference.cs b/Yafc.Model/Data/PageReference.cs index 1aa2f7a7..323f7f49 100644 --- a/Yafc.Model/Data/PageReference.cs +++ b/Yafc.Model/Data/PageReference.cs @@ -1,10 +1,9 @@ using System; namespace Yafc.Model; + public sealed class PageReference(Guid guid) { - public PageReference(ProjectPage page) : this(page.guid) { - _page = page; - } + public PageReference(ProjectPage page) : this(page.guid) => _page = page; public Guid guid { get; } = guid; private ProjectPage? _page; diff --git a/Yafc.Model/Math/Bits.cs b/Yafc.Model/Math/Bits.cs index 4ee06fec..5f7637d2 100644 --- a/Yafc.Model/Math/Bits.cs +++ b/Yafc.Model/Math/Bits.cs @@ -3,6 +3,7 @@ using System.Numerics; namespace Yafc.Model; + public struct Bits { private int _length; public int length { @@ -21,17 +22,21 @@ private set { public bool this[int i] { readonly get { ArgumentOutOfRangeException.ThrowIfNegative(i, nameof(i)); + if (data is null || length <= i) { return false; } + return (data[i / 64] & (1ul << (i % 64))) != 0; } [MemberNotNull(nameof(data))] set { ArgumentOutOfRangeException.ThrowIfNegative(i, nameof(i)); + if (length <= i) { length = i + 1; } + // null-forgiving: length must be non-zero, meaning data cannot be null. if (value) { // set bit @@ -104,6 +109,7 @@ public Bits(Bits original) { if (shift != 1) { throw new NotImplementedException("only shifting by 1 is supported"); } + if (a.data is null) { return default; } Bits result = default; @@ -111,10 +117,12 @@ public Bits(Bits original) { // bits that 'fell off' in the previous shifting operation ulong carrier = 0ul; + for (int i = 0; i < a.data.Length; i++) { result.data[i] = (a.data[i] << shift) | carrier; carrier = a.data[i] & ~(~0ul >> shift); // Mask with 'shift amount of MSB' } + if (carrier != 0) { // Reason why only shift == 1 is supported, it is messy to map the separate bits back on data[length] and data[length - 1] // Since shift != 1 is never used, its implementation is omitted @@ -136,6 +144,7 @@ public Bits(Bits original) { } int maxLength = Math.Max(a.data.Length, b.data.Length); + for (int i = maxLength - 1; i >= 0; i--) { if (a.data.Length <= i) { if (b.data[i] != 0) { @@ -175,6 +184,7 @@ public Bits(Bits original) { } int maxLength = Math.Max(a.data.Length, b.data.Length); + for (int i = maxLength - 1; i >= 0; i--) { if (a.data.Length <= i) { if (b.data[i] != 0) { @@ -216,16 +226,17 @@ public Bits(Bits original) { // Only works for subtracting by 1! // subtract by 1: find lowest bit that is set, unset this bit and set all previous bits int index = 0; + while (result[index] == false) { result[index] = true; index++; } + result[index] = false; return result; } - // Check if the first ulong of a equals to b, rest of a needs to be 0 public static bool operator ==(Bits a, ulong b) { if (a.length == 0) { @@ -268,7 +279,6 @@ public Bits(Bits original) { return true; } - public static bool operator !=(Bits a, Bits b) => !(a == b); public static bool operator !=(Bits a, ulong b) => !(a == b); @@ -277,6 +287,7 @@ public Bits(Bits original) { public override readonly int GetHashCode() { int hash = 7; + unchecked { foreach (ulong i in data ?? []) { hash = (hash * 31) + (int)i; @@ -290,9 +301,11 @@ public override readonly int GetHashCode() { public readonly int HighestBitSet() { int result = -1; + if (data is null) { return result; } + for (int i = 0; i < data.Length; i++) { if (data[i] != 0) { // data[i] contains a (new) highest bit @@ -307,6 +320,7 @@ public readonly int CompareTo(Bits b) { if (this == b) { return 0; } + return this < b ? -1 : 1; } diff --git a/Yafc.Model/Math/Graph.cs b/Yafc.Model/Math/Graph.cs index 1a1382d5..66aef2af 100644 --- a/Yafc.Model/Math/Graph.cs +++ b/Yafc.Model/Math/Graph.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; namespace Yafc.Model; + public class Graph : IEnumerable.Node> where T : notnull { private readonly Dictionary nodes = []; private readonly List allNodes = []; @@ -15,29 +16,17 @@ private Node GetNode(T src) { return nodes[src] = new Node(this, src); } - public void Connect(T from, T to) { - GetNode(from).AddArc(GetNode(to)); - } + public void Connect(T from, T to) => GetNode(from).AddArc(GetNode(to)); - public bool HasConnection(T from, T to) { - return GetNode(from).HasConnection(GetNode(to)); - } + public bool HasConnection(T from, T to) => GetNode(from).HasConnection(GetNode(to)); - public ArraySegment GetConnections(T from) { - return GetNode(from).Connections; - } + public ArraySegment GetConnections(T from) => GetNode(from).Connections; - public List.Enumerator GetEnumerator() { - return allNodes.GetEnumerator(); - } + public List.Enumerator GetEnumerator() => allNodes.GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - IEnumerator IEnumerable.GetEnumerator() { - return GetEnumerator(); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public class Node { public readonly T userData; @@ -68,15 +57,15 @@ public void AddArc(Node node) { public ArraySegment Connections => new ArraySegment(arcs, 0, arcCount); - public bool HasConnection(Node node) { - return Array.IndexOf(arcs, node, 0, arcCount) >= 0; - } + public bool HasConnection(Node node) => Array.IndexOf(arcs, node, 0, arcCount) >= 0; } public Graph Remap(Dictionary mapping) where TMap : notnull { Graph remapped = new Graph(); + foreach (var node in allNodes) { var remappedNode = mapping[node.userData]; + foreach (var connection in node.Connections) { remapped.Connect(remappedNode, mapping[connection.userData]); } @@ -87,6 +76,7 @@ public Graph Remap(Dictionary mapping) where TMap : notnull public Dictionary Aggregate(Func create, Action connection) { Dictionary aggregation = []; + foreach (var node in allNodes) { _ = AggregateInternal(node, create, connection, aggregation); } @@ -101,6 +91,7 @@ private TValue AggregateInternal(Node node, Func create, Acti result = create(node.userData); dict[node.userData] = result; + foreach (var con in node.Connections) { connection(result, con.userData, AggregateInternal(con, create, connection, dict)); } @@ -116,16 +107,17 @@ private TValue AggregateInternal(Node node, Func create, Acti Dictionary remap = []; List stack = []; int index = 0; + foreach (var node in allNodes) { if (node.state == -1) { - StrongConnect(stack, node, remap, ref index); + Graph.StrongConnect(stack, node, remap, ref index); } } return Remap(remap); } - private void StrongConnect(List stack, Node root, Dictionary remap, ref int index) { + private static void StrongConnect(List stack, Node root, Dictionary remap, ref int index) { // Algorithm from https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm // index => state // lowlink => extra @@ -135,9 +127,10 @@ private void StrongConnect(List stack, Node root, Dictionary neighbor root.extra = root.state = index++; stack.Add(root); + foreach (var neighbor in root.Connections) { if (neighbor.state == -1) { - StrongConnect(stack, neighbor, remap, ref index); + Graph.StrongConnect(stack, neighbor, remap, ref index); root.extra = Math.Min(root.extra, neighbor.extra); } else if (neighbor.state >= 0) { @@ -148,11 +141,13 @@ private void StrongConnect(List stack, Node root, Dictionary Solve(ProjectPage page) { Queue processingStack = new Queue(); var bestFlowSolver = DataUtils.CreateSolver(); var rootConstraint = bestFlowSolver.MakeConstraint(); + foreach (var root in roots) { processedGoods[root] = rootConstraint; } @@ -55,10 +57,11 @@ public override async Task Solve(ProjectPage page) { objective.SetMinimization(); processingStack.Enqueue(null); // depth marker; int depth = 0; - List allRecipes = []; + while (processingStack.Count > 1) { var item = processingStack.Dequeue(); + if (item == null) { processingStack.Enqueue(null); depth++; @@ -66,6 +69,7 @@ public override async Task Solve(ProjectPage page) { } var constraint = processedGoods[item]; + foreach (var recipe in item.production) { if (!recipe.IsAccessibleWithCurrentMilestones()) { continue; @@ -88,6 +92,7 @@ public override async Task Solve(ProjectPage page) { foreach (var ingredient in recipe.ingredients) { var proc = processedGoods[ingredient.goods]; + if (proc == rootConstraint) { continue; } @@ -108,9 +113,11 @@ public override async Task Solve(ProjectPage page) { var solverResult = bestFlowSolver.Solve(); logger.Information("Solution completed with result {result}", solverResult); + if (solverResult is not Solver.ResultStatus.OPTIMAL and not Solver.ResultStatus.FEASIBLE) { logger.Information(bestFlowSolver.ExportModelAsLpFormat(false)); this.tiers = null; + return "Model has no solution"; } @@ -146,10 +153,13 @@ public override async Task Solve(ProjectPage page) { }); Dictionary> downstream = []; Dictionary> upstream = []; + foreach (var ((single, list), dependencies) in allDependencies) { HashSet deps = []; + foreach (var (singleDep, listDep) in dependencies) { var elem = singleDep; + if (listDep != null) { deps.UnionWith(listDep); elem = listDep[0]; @@ -160,6 +170,7 @@ public override async Task Solve(ProjectPage page) { if (!upstream.TryGetValue(elem, out var set)) { set = []; + if (listDep != null) { foreach (var recipe in listDep) { upstream[recipe] = set; @@ -192,8 +203,10 @@ public override async Task Solve(ProjectPage page) { List<(Recipe, Recipe[])> nodesToClear = []; List tiers = []; List currentTier = []; + while (remainingNodes.Count > 0) { currentTier.Clear(); + // First attempt to create tier: Immediately accessible recipe foreach (var node in remainingNodes) { if (node.Item2 != null && currentTier.Count > 0) { @@ -207,13 +220,16 @@ public override async Task Solve(ProjectPage page) { } nodesToClear.Add(node); + if (node.Item2 != null) { currentTier.AddRange(node.Item2); break; } + currentTier.Add(node.Item1); nope:; } + remainingNodes.ExceptWith(nodesToClear); if (currentTier.Count == 0) // whoops, give up @@ -226,6 +242,7 @@ public override async Task Solve(ProjectPage page) { currentTier.Add(single); } } + remainingNodes.Clear(); logger.Information("Tier creation failure"); } @@ -241,6 +258,7 @@ public override async Task Solve(ProjectPage page) { await Ui.EnterMainThread(); this.tiers = [.. tiers]; + return null; } diff --git a/Yafc.Model/Model/ModuleFillerParameters.cs b/Yafc.Model/Model/ModuleFillerParameters.cs index 314b1ee0..deca0ffb 100644 --- a/Yafc.Model/Model/ModuleFillerParameters.cs +++ b/Yafc.Model/Model/ModuleFillerParameters.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; namespace Yafc.Model; + /// /// An entry in the per-crafter beacon override configuration. It must specify both a beacon and a module, but it may specify zero beacons. /// @@ -21,7 +22,8 @@ public record BeaconOverrideConfiguration(EntityBeacon beacon, int beaconCount, /// The module to place in the beacon, or if no beacons or beacon modules should be used. [Serializable] public record BeaconConfiguration(EntityBeacon? beacon, int beaconCount, Module? beaconModule) { - public static implicit operator BeaconConfiguration(BeaconOverrideConfiguration beaconConfiguration) => new(beaconConfiguration.beacon, beaconConfiguration.beaconCount, beaconConfiguration.beaconModule); + public static implicit operator BeaconConfiguration(BeaconOverrideConfiguration beaconConfiguration) => + new(beaconConfiguration.beacon, beaconConfiguration.beaconCount, beaconConfiguration.beaconModule); } /// @@ -83,7 +85,7 @@ private void ChangeModuleFillerParameters(ref T field, T value) { private Action? ModuleFillerParametersChanging(EntityCrafter? crafter = null) { if (SerializationMap.IsDeserializing) { return null; } // Deserializing; don't do anything fancy. - this.RecordUndo(); + _ = this.RecordUndo(); ModelObject parent = owner; while (parent.ownerObject is not ProjectPage and not null) { parent = parent.ownerObject; @@ -120,8 +122,11 @@ internal void AutoFillBeacons(RecipeOrTechnology recipe, EntityCrafter entity, r } } - private void AutoFillModules((float recipeTime, float fuelUsagePerSecondPerBuilding) partialParams, RecipeRow row, EntityCrafter entity, ref ModuleEffects effects, ref UsedModule used) { + private void AutoFillModules((float recipeTime, float fuelUsagePerSecondPerBuilding) partialParams, RecipeRow row, + EntityCrafter entity, ref ModuleEffects effects, ref UsedModule used) { + RecipeOrTechnology recipe = row.recipe; + if (autoFillPayback > 0 && (fillMiners || !recipe.flags.HasFlags(RecipeFlags.UsesMiningProductivity))) { /* Auto Fill Calculation @@ -148,17 +153,20 @@ The payback time is calculated as the module cost divided by the economy gain pe float productivityEconomy = recipe.Cost() / partialParams.recipeTime; float speedEconomy = Math.Max(0.0001f, entity.Cost()) / autoFillPayback; float effectivityEconomy = partialParams.fuelUsagePerSecondPerBuilding * row.fuel?.Cost() ?? 0f; + if (effectivityEconomy < 0f) { effectivityEconomy = 0f; } float bestEconomy = 0f; Module? usedModule = null; + foreach (var module in recipe.modules) { if (module.IsAccessibleWithCurrentMilestones() && entity.CanAcceptModule(module.moduleSpecification)) { float economy = module.moduleSpecification.productivity * productivityEconomy + module.moduleSpecification.speed * speedEconomy - module.moduleSpecification.consumption * effectivityEconomy; + if (economy > bestEconomy && module.Cost() / economy <= autoFillPayback) { bestEconomy = economy; usedModule = module; @@ -168,9 +176,11 @@ The payback time is calculated as the module cost divided by the economy gain pe if (usedModule != null) { int count = effects.GetModuleSoftLimit(usedModule.moduleSpecification, entity.moduleSlots); + if (count > 0) { effects.AddModules(usedModule.moduleSpecification, count); - used.modules = new[] { (usedModule, count, false) }; + used.modules = [(usedModule, count, false)]; + return; } } @@ -186,11 +196,11 @@ internal void GetModulesInfo((float recipeTime, float fuelUsagePerSecondPerBuild AutoFillModules(partialParams, row, entity, ref effects, ref used); } - private void AddModuleSimple(Module module, ref ModuleEffects effects, EntityCrafter entity, ref UsedModule used) { + private static void AddModuleSimple(Module module, ref ModuleEffects effects, EntityCrafter entity, ref UsedModule used) { if (module.moduleSpecification != null) { int fillerLimit = effects.GetModuleSoftLimit(module.moduleSpecification, entity.moduleSlots); effects.AddModules(module.moduleSpecification, fillerLimit); - used.modules = new[] { (module, fillerLimit, false) }; + used.modules = [(module, fillerLimit, false)]; } } } @@ -261,6 +271,7 @@ public bool Remove(EntityCrafter key) { Action? action = OverrideSettingChanging?.Invoke(key); bool result = storage.Remove(key); action?.Invoke(); + return result; } @@ -269,6 +280,7 @@ bool ICollection>.Remov Action? action = OverrideSettingChanging?.Invoke(item.Key); bool result = ((ICollection>)storage).Remove(item); action?.Invoke(); + return result; } diff --git a/Yafc.Model/Model/ProductionSummary.cs b/Yafc.Model/Model/ProductionSummary.cs index 2dcc33b0..d9444325 100644 --- a/Yafc.Model/Model/ProductionSummary.cs +++ b/Yafc.Model/Model/ProductionSummary.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc.Model; + public class ProductionSummaryGroup(ModelObject owner) : ModelObject(owner), IElementGroup { public List elements { get; } = []; [NoUndo] @@ -78,6 +79,7 @@ public string name { public bool CollectSolvingTasks(List listToFill) { var solutionTask = SolveIfNecessary(); + if (solutionTask != null) { listToFill.Add(solutionTask); needRefreshFlow = true; @@ -88,6 +90,7 @@ public bool CollectSolvingTasks(List listToFill) { needRefreshFlow |= element.CollectSolvingTasks(listToFill); } } + return needRefreshFlow; } @@ -97,6 +100,7 @@ public bool CollectSolvingTasks(List listToFill) { } var solutionPagepage = page.page; + if (solutionPagepage != null && solutionPagepage.IsSolutionStale()) { return solutionPagepage.ExternalSolve(); } @@ -104,9 +108,7 @@ public bool CollectSolvingTasks(List listToFill) { return null; } - public float GetAmount(Goods goods) { - return flow.TryGetValue(goods, out float amount) ? amount : 0; - } + public float GetAmount(Goods goods) => flow.TryGetValue(goods, out float amount) ? amount : 0; public void RefreshFlow() { if (!needRefreshFlow) { @@ -115,6 +117,7 @@ public void RefreshFlow() { needRefreshFlow = false; flow.Clear(); + if (subgroup != null) { subgroup.Solve(flow, multiplier); } @@ -138,9 +141,7 @@ public void RefreshFlow() { } } - public void SetOwner(ProductionSummaryGroup newOwner) { - owner = newOwner; - } + public void SetOwner(ProductionSummaryGroup newOwner) => owner = newOwner; public void UpdateFilter(Goods goods, SearchQuery query) { visible = flow.ContainsKey(goods); @@ -159,9 +160,7 @@ public class ProductionSummaryColumn(ProductionSummary owner, Goods goods) : Mod } public class ProductionSummary : ProjectPageContents, IComparer<(Goods goods, float amount)> { - public ProductionSummary(ModelObject page) : base(page) { - group = new ProductionSummaryGroup(this); - } + public ProductionSummary(ModelObject page) : base(page) => group = new ProductionSummaryGroup(this); public ProductionSummaryGroup group { get; } public List columns { get; } = []; [SkipSerialization] public List<(Goods goods, float amount)> sortedFlow { get; } = []; @@ -174,12 +173,11 @@ public override void InitNew() { base.InitNew(); } - public float GetTotalFlow(Goods goods) { - return totalFlow.TryGetValue(goods, out float amount) ? amount : 0; - } + public float GetTotalFlow(Goods goods) => totalFlow.TryGetValue(goods, out float amount) ? amount : 0; public override async Task Solve(ProjectPage page) { List taskList = []; + foreach (var element in group.elements) { _ = element.CollectSolvingTasks(taskList); } @@ -196,17 +194,20 @@ public float GetTotalFlow(Goods goods) { } sortedFlow.Clear(); + foreach (var element in totalFlow) { sortedFlow.Add((element.Key, element.Value)); } sortedFlow.Sort(this); + return null; } public int Compare((Goods goods, float amount) x, (Goods goods, float amount) y) { float amt1 = x.goods.fluid != null ? x.amount / 50f : x.amount; float amt2 = y.goods.fluid != null ? y.amount / 50f : y.amount; + return amt1.CompareTo(amt2); } } diff --git a/Yafc.Model/Model/ProductionTable.cs b/Yafc.Model/Model/ProductionTable.cs index 48608dd1..c0521aac 100644 --- a/Yafc.Model/Model/ProductionTable.cs +++ b/Yafc.Model/Model/ProductionTable.cs @@ -10,6 +10,7 @@ using Yafc.UI; namespace Yafc.Model; + public struct ProductionTableFlow(Goods goods, float amount, ProductionLink? link) { public Goods goods = goods; public float amount = amount; @@ -46,6 +47,7 @@ protected internal override void ThisChanged(bool visualOnly) { public void RebuildLinkMap() { linkMap.Clear(); + foreach (var link in links) { linkMap[link.goods] = link; } @@ -79,8 +81,10 @@ private static void ClearDisabledRecipeContents(RecipeRow recipe) { recipe.parameters = RecipeParameters.Empty; recipe.hierarchyEnabled = false; var subgroup = recipe.subgroup; + if (subgroup != null) { subgroup.flow = []; + foreach (var link in subgroup.links) { link.flags = 0; link.linkFlow = 0; @@ -96,6 +100,7 @@ public bool Search(SearchQuery query) { foreach (var recipe in recipes) { recipe.visible = false; + if (recipe.subgroup != null && recipe.subgroup.Search(query)) { goto match; } @@ -115,6 +120,7 @@ public bool Search(SearchQuery query) { goto match; } } + continue; // no match; match: hasMatch = true; @@ -149,6 +155,7 @@ public void AddRecipe(RecipeOrTechnology recipe, IComparer ingredientVari EntityCrafter? spentFuelRecipeCrafter = GetSpentFuelCrafter(recipe, spentFuel); recipeRow.entity = selectedFuelCrafter ?? spentFuelRecipeCrafter ?? recipe.crafters.AutoSelect(DataUtils.FavoriteCrafter); + if (recipeRow.entity != null) { recipeRow.fuel = GetSelectedFuel(selectedFuel, recipeRow) ?? GetFuelForSpentFuel(spentFuel, recipeRow) @@ -171,6 +178,7 @@ public void AddRecipe(RecipeOrTechnology recipe, IComparer ingredientVari if (spentFuel is null) { return null; } + return recipe.crafters .Where(c => c.energy.fuels.OfType().Any(e => e.fuelResult == spentFuel)) .AutoSelect(DataUtils.FavoriteCrafter); @@ -180,6 +188,7 @@ public void AddRecipe(RecipeOrTechnology recipe, IComparer ingredientVari if (spentFuel is null) { return null; } + return recipeRow.entity?.energy.fuels.Where(e => spentFuel.miscSources.Contains(e)) .AutoSelect(DataUtils.FavoriteFuel); } @@ -197,6 +206,7 @@ public IEnumerable GetAllRecipes() { static IEnumerable flatten(IEnumerable rows) { foreach (var row in rows) { yield return row; + if (row.subgroup is not null) { foreach (var row2 in flatten(row.subgroup.GetAllRecipes())) { yield return row2; @@ -217,6 +227,7 @@ private static void AddFlow(RecipeRow recipe, Dictionary flowDict = []; + if (include != null) { AddFlow(include, flowDict); } @@ -255,6 +268,7 @@ private void CalculateFlow(RecipeRow? include) { recipe.subgroup.CalculateFlow(recipe); foreach (var elem in recipe.subgroup.flow) { _ = flowDict.TryGetValue(elem.goods, out var prev); + if (elem.amount > 0f) { prev.prod += elem.amount; } @@ -272,6 +286,7 @@ private void CalculateFlow(RecipeRow? include) { foreach (ProductionLink link in links) { (double prod, double cons) flowParams; + if (!link.flags.HasFlagAny(ProductionLink.Flags.LinkNotMatched)) { _ = flowDict.Remove(link.goods, out flowParams); } @@ -281,15 +296,18 @@ private void CalculateFlow(RecipeRow? include) { parent.flags |= ProductionLink.Flags.ChildNotMatched | ProductionLink.Flags.LinkNotMatched; } } + link.linkFlow = (float)flowParams.prod; } ProductionTableFlow[] flowArr = new ProductionTableFlow[flowDict.Count]; int index = 0; + foreach (var (k, (prod, cons)) in flowDict) { _ = FindLink(k, out var link); flowArr[index++] = new ProductionTableFlow(k, (float)(prod - cons), link); } + Array.Sort(flowArr, 0, flowArr.Length, this); flow = flowArr; } @@ -301,7 +319,7 @@ private void CalculateFlow(RecipeRow? include) { private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink link, RecipeRow recipe, float amount) { // GetCoefficient will return 0 when the variable is not available in the constraint amount += (float)cst.GetCoefficient(var); - link.capturedRecipes.Add(recipe); + _ = link.capturedRecipes.Add(recipe); cst.SetCoefficient(var, amount); } @@ -319,6 +337,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin var recipe = allRecipes[i]; recipe.parameters = RecipeParameters.CalculateParameters(recipe); var variable = productionTableSolver.MakeNumVar(0f, double.PositiveInfinity, recipe.recipe.name); + if (recipe.fixedBuildings > 0f) { double fixedRps = (double)recipe.fixedBuildings / recipe.parameters.recipeTime; variable.SetBounds(fixedRps, fixedRps); @@ -327,6 +346,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin } Constraint[] constraints = new Constraint[allLinks.Count]; + for (int i = 0; i < allLinks.Count; i++) { var link = allLinks[i]; float min = link.algorithm == LinkAlgorithm.AllowOverConsumption ? float.NegativeInfinity : link.amount; @@ -344,6 +364,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin for (int j = 0; j < recipe.recipe.products.Length; j++) { var product = recipe.recipe.products[j]; + if (product.amount <= 0f) { continue; } @@ -353,6 +374,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin float added = product.GetAmountPerRecipe(recipe.parameters.productivity); AddLinkCoef(constraints[link.solverIndex], recipeVar, link, recipe, added); float cost = product.goods.Cost(); + if (cost > 0f) { objCoefs[i] += added * cost; } @@ -364,6 +386,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin for (int j = 0; j < recipe.recipe.ingredients.Length; j++) { var ingredient = recipe.recipe.ingredients[j]; var option = ingredient.variants == null ? ingredient.goods : recipe.GetVariant(ingredient.variants); + if (recipe.FindLink(option, out var link)) { link.flags |= ProductionLink.Flags.HasConsumption; AddLinkCoef(constraints[link.solverIndex], recipeVar, link, recipe, -ingredient.amount); @@ -377,6 +400,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin if (recipe.fuel != null) { float fuelAmount = recipe.parameters.fuelUsagePerSecondPerRecipe; + if (recipe.FindLink(recipe.fuel, out var link)) { links.fuel = link; link.flags |= ProductionLink.Flags.HasConsumption; @@ -387,6 +411,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin links.spentFuel = link; link.flags |= ProductionLink.Flags.HasProduction; AddLinkCoef(constraints[link.solverIndex], recipeVar, link, recipe, fuelAmount); + if (spentFuel.Cost() > 0f) { objCoefs[i] += fuelAmount * spentFuel.Cost(); } @@ -398,6 +423,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin foreach (var link in allLinks) { link.notMatchedFlow = 0f; + if (!link.flags.HasFlags(ProductionLink.Flags.HasProductionAndConsumption)) { if (!link.flags.HasFlagAny(ProductionLink.Flags.HasProductionAndConsumption) && !link.owner.HasDisabledRecipeReferencing(link.goods)) { _ = link.owner.RecordUndo(true).links.Remove(link); @@ -409,15 +435,18 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin } await Ui.ExitMainThread(); + for (int i = 0; i < allRecipes.Count; i++) { objective.SetCoefficient(vars[i], (allRecipes[i].recipe as Recipe)?.RecipeBaseCost() ?? 0); } var result = productionTableSolver.Solve(); + if (result is not Solver.ResultStatus.FEASIBLE and not Solver.ResultStatus.OPTIMAL) { objective.Clear(); var (deadlocks, splits) = GetInfeasibilityCandidates(allRecipes); (Variable? positive, Variable? negative)[] slackVars = new (Variable? positive, Variable? negative)[allLinks.Count]; + // Solution does not exist. Adding slack variables to find the reason foreach (var link in deadlocks) { // Adding negative slack to possible deadlocks (loops) @@ -446,8 +475,10 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin if (result is Solver.ResultStatus.OPTIMAL or Solver.ResultStatus.FEASIBLE) { List linkList = []; + for (int i = 0; i < allLinks.Count; i++) { var (posSlack, negSlack) = slackVars[i]; + if (posSlack is not null && posSlack.BasisStatus() != Solver.BasisStatus.AT_LOWER_BOUND) { linkList.Add(allLinks[i]); allLinks[i].notMatchedFlow += (float)posSlack.SolutionValue(); @@ -466,6 +497,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin link.flags |= ProductionLink.Flags.LinkNotMatched | ProductionLink.Flags.LinkRecursiveNotMatched; RecipeRow? ownerRecipe = link.owner.owner as RecipeRow; + while (ownerRecipe != null) { if (link.notMatchedFlow > 0f) { ownerRecipe.parameters.warningFlags |= WarningFlags.OverproductionRequired; @@ -480,6 +512,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin foreach (var recipe in allRecipes) { FindAllRecipeLinks(recipe, linkList, linkList); + foreach (var link in linkList) { if (link.flags.HasFlags(ProductionLink.Flags.LinkRecursiveNotMatched)) { if (link.notMatchedFlow > 0f) { @@ -509,11 +542,13 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin var link = allLinks[i]; var constraint = constraints[i]; link.dualValue = (float)constraint.DualValue(); + if (constraint == null) { continue; } var basisStatus = constraint.BasisStatus(); + if ((basisStatus == Solver.BasisStatus.BASIC || basisStatus == Solver.BasisStatus.FREE) && (link.notMatchedFlow != 0 || link.algorithm != LinkAlgorithm.Match)) { link.flags |= ProductionLink.Flags.LinkNotMatched; } @@ -528,6 +563,7 @@ private static void AddLinkCoef(Constraint cst, Variable var, ProductionLink lin bool builtCountExceeded = CheckBuiltCountExceeded(); CalculateFlow(null); + return builtCountExceeded ? "This model requires more buildings than are currently built" : null; } @@ -542,8 +578,10 @@ private bool HasDisabledRecipeReferencing(Goods goods) private bool CheckBuiltCountExceeded() { bool builtCountExceeded = false; + for (int i = 0; i < recipes.Count; i++) { var recipe = recipes[i]; + if (recipe.buildingCount > recipe.builtBuildings) { recipe.parameters.warningFlags |= WarningFlags.ExceedsBuiltCount; builtCountExceeded = true; @@ -562,6 +600,7 @@ private bool CheckBuiltCountExceeded() { private static void FindAllRecipeLinks(RecipeRow recipe, List sources, List targets) { sources.Clear(); targets.Clear(); + foreach (var link in recipe.links.products) { if (link != null) { targets.Add(link); @@ -591,6 +630,7 @@ private static (List merges, List splits) GetInf foreach (var recipe in recipes) { FindAllRecipeLinks(recipe, sources, targets); + foreach (var src in sources) { foreach (var tgt in targets) { graph.Connect(src, tgt); @@ -604,11 +644,13 @@ private static (List merges, List splits) GetInf var loops = graph.MergeStrongConnectedComponents(); sources.Clear(); + foreach (var possibleLoop in loops) { if (possibleLoop.userData.list != null) { var list = possibleLoop.userData.list; var last = list[^1]; sources.Add(last); + for (int i = 0; i < list.Length - 1; i++) { for (int j = i + 2; j < list.Length; j++) { if (graph.HasConnection(list[i], list[j])) { @@ -628,7 +670,9 @@ public bool FindLink(Goods goods, [MaybeNullWhen(false)] out ProductionLink link link = null; return false; } + var searchFrom = this; + while (true) { if (searchFrom.linkMap.TryGetValue(goods, out link)) { return true; @@ -646,6 +690,7 @@ public bool FindLink(Goods goods, [MaybeNullWhen(false)] out ProductionLink link public int Compare(ProductionTableFlow x, ProductionTableFlow y) { float amt1 = x.goods.fluid != null ? x.amount / 50f : x.amount; float amt2 = y.goods.fluid != null ? y.amount / 50f : y.amount; + return amt1.CompareTo(amt2); } } diff --git a/Yafc.Model/Model/ProductionTableContent.cs b/Yafc.Model/Model/ProductionTableContent.cs index f694c9be..5b382d48 100644 --- a/Yafc.Model/Model/ProductionTableContent.cs +++ b/Yafc.Model/Model/ProductionTableContent.cs @@ -6,6 +6,7 @@ using Yafc.UI; namespace Yafc.Model; + public struct ModuleEffects { public float speed; public float productivity; @@ -28,6 +29,7 @@ public void AddModules(ModuleSpecification module, float count, AllowedEffects a public void AddModules(ModuleSpecification module, float count) { speed += module.speed * count; + if (module.productivity > 0f) { productivity += module.productivity * count; } @@ -91,8 +93,10 @@ public bool IsCompatibleWith([NotNullWhen(true)] RecipeRow? row) { bool hasFloodfillModules = false; bool hasCompatibleFloodfill = false; int totalModules = 0; + foreach (var module in list) { bool isCompatibleWithModule = row.recipe.CanAcceptModule(module.module) && row.entity.CanAcceptModule(module.module.moduleSpecification); + if (module.fixedCount == 0) { hasFloodfillModules = true; hasCompatibleFloodfill |= isCompatibleWithModule; @@ -115,6 +119,7 @@ internal void GetModulesInfo(RecipeRow row, EntityCrafter entity, ref ModuleEffe Item? nonBeacon = null; used.modules = null; int remaining = entity.moduleSlots; + foreach (var module in list) { if (!entity.CanAcceptModule(module.module.moduleSpecification) || !row.recipe.CanAcceptModule(module.module)) { continue; @@ -154,7 +159,9 @@ public int CalcBeaconCount() { if (beacon is null) { throw new InvalidOperationException($"Must not call {nameof(CalcBeaconCount)} when {nameof(beacon)} is null."); } + int moduleCount = 0; + foreach (var element in beaconList) { moduleCount += element.fixedCount; } @@ -177,6 +184,7 @@ internal static ModuleTemplate Build(ModelObject owner, ModuleTemplateBuilder bu #pragma warning restore IDE0017 modules.list = convertList(builder.list); modules.beaconList = convertList(builder.beaconList); + return modules; ReadOnlyCollection convertList(List<(Module module, int fixedCount)> list) @@ -270,15 +278,19 @@ public Goods? fuel { else { // We're changing the fuel and at least one of the current or new fuel burns to the fixed product double oldAmount = product.GetAmountForRow(this); + if ((fuel as Item)?.fuelResult == fixedProduct) { oldAmount += fuelUsagePerSecond; } + _fuel = value; parameters = RecipeParameters.CalculateParameters(this); double newAmount = product.GetAmountForRow(this); + if ((fuel as Item)?.fuelResult == fixedProduct) { newAmount += fuelUsagePerSecond; } + fixedBuildings *= (float)(oldAmount / newAmount); } } @@ -406,13 +418,16 @@ public IEnumerable Products { int i = 0; Item? spentFuel = (fuel as Item)?.fuelResult; bool handledFuel = spentFuel == null; // If there's no spent fuel, it's already handled + foreach (Product product in recipe.products) { if (hierarchyEnabled) { float amount = product.GetAmountForRow(this); + if (product.goods == spentFuel) { amount += fuelUsagePerSecond; handledFuel = true; } + yield return (product.goods, amount, links.products[i++]); } else { @@ -432,9 +447,7 @@ public IEnumerable Products { internal float fuelUsagePerSecond => (float)(parameters.fuelUsagePerSecondPerRecipe * recipesPerSecond); public UsedModule usedModules => parameters.modules; public WarningFlags warningFlags => parameters.warningFlags; - public bool FindLink(Goods goods, [MaybeNullWhen(false)] out ProductionLink link) { - return linkRoot.FindLink(goods, out link); - } + public bool FindLink(Goods goods, [MaybeNullWhen(false)] out ProductionLink link) => linkRoot.FindLink(goods, out link); public T GetVariant(T[] options) where T : FactorioObject { foreach (var option in options) { @@ -458,6 +471,7 @@ public void ChangeVariant(T was, T now) where T : FactorioObject { public RecipeRow(ProductionTable owner, RecipeOrTechnology recipe) : base(owner) { this.recipe = recipe ?? throw new ArgumentNullException(nameof(recipe), "Recipe does not exist"); + links = new RecipeLinks { ingredients = new ProductionLink[recipe.ingredients.Length], ingredientGoods = new Goods[recipe.ingredients.Length], @@ -465,13 +479,9 @@ public RecipeRow(ProductionTable owner, RecipeOrTechnology recipe) : base(owner) }; } - protected internal override void ThisChanged(bool visualOnly) { - owner.ThisChanged(visualOnly); - } + protected internal override void ThisChanged(bool visualOnly) => owner.ThisChanged(visualOnly); - public void SetOwner(ProductionTable parent) { - owner = parent; - } + public void SetOwner(ProductionTable parent) => owner = parent; public void RemoveFixedModules() { if (modules == null) { @@ -523,7 +533,8 @@ internal void GetModulesInfo((float recipeTime, float fuelUsagePerSecondPerBuild /// /// Call to inform this that the applicable are about to change. /// - /// If not , an to perform after the change has completed that will update to account for the new modules. + /// If not , an to perform after the change has completed + /// that will update to account for the new modules. internal Action? ModuleFillerParametersChanging() { if (fixedFuel || fixedIngredient != null || fixedProduct != null) { return new ChangeModulesOrEntity(this).Dispose; @@ -541,7 +552,7 @@ private class ChangeModulesOrEntity : IDisposable { public ChangeModulesOrEntity(RecipeRow row) { this.row = row; - row.RecordUndo(); // Unnecessary (but not harmful) when called by set_modules or set_entity. Required when called by ModuleFillerParametersChanging. + _ = row.RecordUndo(); // Unnecessary (but not harmful) when called by set_modules or set_entity. Required when called by ModuleFillerParametersChanging. // Changing the modules or entity requires up to four steps: // (1) Change the fuel to void (boosting fixedBuildings in RecipeRow.set_fuel to account for lost fuel consumption) @@ -562,6 +573,7 @@ public ChangeModulesOrEntity(RecipeRow row) { public void Dispose() { row.parameters = RecipeParameters.CalculateParameters(row); + if (row.fixedFuel) { row.fixedBuildings *= oldParameters.fuelUsagePerSecondPerBuilding / row.parameters.fuelUsagePerSecondPerBuilding; // step 3, for fixed fuels } @@ -677,8 +689,10 @@ public IEnumerable LinkWarnings { } public record RecipeRowIngredient(Goods? Goods, float Amount, ProductionLink? Link, Goods[]? Variants) { - public static implicit operator (Goods? Goods, float Amount, ProductionLink? Link, Goods[]? Variants)(RecipeRowIngredient value) => (value.Goods, value.Amount, value.Link, value.Variants); - public static implicit operator RecipeRowIngredient((Goods? Goods, float Amount, ProductionLink? Link, Goods[]? Variants) value) => new(value.Goods, value.Amount, value.Link, value.Variants); + public static implicit operator (Goods? Goods, float Amount, ProductionLink? Link, Goods[]? Variants)(RecipeRowIngredient value) + => (value.Goods, value.Amount, value.Link, value.Variants); + public static implicit operator RecipeRowIngredient((Goods? Goods, float Amount, ProductionLink? Link, Goods[]? Variants) value) + => new(value.Goods, value.Amount, value.Link, value.Variants); } public record RecipeRowProduct(Goods? Goods, float Amount, ProductionLink? Link) { diff --git a/Yafc.Model/Model/Project.cs b/Yafc.Model/Model/Project.cs index 91f6baa8..19ff5a60 100644 --- a/Yafc.Model/Model/Project.cs +++ b/Yafc.Model/Model/Project.cs @@ -6,6 +6,7 @@ using System.Text.Json; namespace Yafc.Model; + public class Project : ModelObject { public static Project current { get; set; } = null!; // null-forgiving: MainScreen.SetProject will set this to a non-null value public static Version currentYafcVersion { get; set; } = new Version(0, 4, 0); @@ -40,10 +41,12 @@ public override ModelObject? ownerObject { private void UpdatePageMapping() { hiddenPages = 0; pagesByGuid.Clear(); + foreach (var page in pages) { pagesByGuid[page.guid] = page; page.visible = false; } + foreach (var page in displayPages) { if (pagesByGuid.TryGetValue(page, out var dpage)) { dpage.visible = true; @@ -60,6 +63,7 @@ private void UpdatePageMapping() { protected internal override void ThisChanged(bool visualOnly) { UpdatePageMapping(); base.ThisChanged(visualOnly); + foreach (var page in pages) { page.SetToRecalculate(); } @@ -69,6 +73,7 @@ protected internal override void ThisChanged(bool visualOnly) { public static Project ReadFromFile(string path, ErrorCollector collector) { Project? proj; + if (!string.IsNullOrEmpty(path) && File.Exists(path)) { proj = Read(File.ReadAllBytes(path), collector); } @@ -78,6 +83,7 @@ public static Project ReadFromFile(string path, ErrorCollector collector) { proj.attachedFileName = path; proj.lastSavedVersion = proj.projectVersion; + return proj; } @@ -87,6 +93,7 @@ public static Project Read(byte[] bytes, ErrorCollector collector) { _ = reader.Read(); DeserializationContext context = new DeserializationContext(collector); proj = SerializationMap.DeserializeFromJson(null, ref reader, context); + if (!reader.IsFinalBlock) { collector.Error("Json was not consumed to the end!", ErrorSeverity.MajorDataLoss); } @@ -97,6 +104,7 @@ public static Project Read(byte[] bytes, ErrorCollector collector) { proj.justCreated = false; Version version = new Version(proj.yafcVersion ?? "0.0"); + if (version != currentYafcVersion) { if (version > currentYafcVersion) { collector.Error("This file was created with future YAFC version. This may lose data.", ErrorSeverity.Important); @@ -104,7 +112,9 @@ public static Project Read(byte[] bytes, ErrorCollector collector) { proj.yafcVersion = currentYafcVersion.ToString(); } + context.Notify(); + return proj; } @@ -131,29 +141,25 @@ public void RecalculateDisplayPages() { } } - public (float multiplier, string suffix) ResolveUnitOfMeasure(UnitOfMeasure unit) { - return unit switch { - UnitOfMeasure.Percent => (100f, "%"), - UnitOfMeasure.Second => (1f, "s"), - UnitOfMeasure.PerSecond => preferences.GetPerTimeUnit(), - UnitOfMeasure.ItemPerSecond => preferences.GetItemPerTimeUnit(), - UnitOfMeasure.FluidPerSecond => preferences.GetFluidPerTimeUnit(), - UnitOfMeasure.Megawatt => (1e6f, "W"), - UnitOfMeasure.Megajoule => (1e6f, "J"), - UnitOfMeasure.Celsius => (1f, "°"), - _ => (1f, ""), - }; - } + public (float multiplier, string suffix) ResolveUnitOfMeasure(UnitOfMeasure unit) => unit switch { + UnitOfMeasure.Percent => (100f, "%"), + UnitOfMeasure.Second => (1f, "s"), + UnitOfMeasure.PerSecond => preferences.GetPerTimeUnit(), + UnitOfMeasure.ItemPerSecond => preferences.GetItemPerTimeUnit(), + UnitOfMeasure.FluidPerSecond => preferences.GetFluidPerTimeUnit(), + UnitOfMeasure.Megawatt => (1e6f, "W"), + UnitOfMeasure.Megajoule => (1e6f, "J"), + UnitOfMeasure.Celsius => (1f, "°"), + _ => (1f, ""), + }; - public ProjectPage? FindPage(Guid guid) { - return pagesByGuid.TryGetValue(guid, out var page) ? page : null; - } + public ProjectPage? FindPage(Guid guid) => pagesByGuid.TryGetValue(guid, out var page) ? page : null; public void RemovePage(ProjectPage page) { page.MarkAsDeleted(); _ = this.RecordUndo(); - pages.Remove(page); - displayPages.Remove(page.guid); + _ = pages.Remove(page); + _ = displayPages.Remove(page.guid); } /// @@ -173,15 +179,19 @@ public void RemovePage(ProjectPage page) { } return pagesByGuid[displayPages.First()]; } + var currentGuid = currentPage.guid; - var currentVisualIndex = displayPages.IndexOf(currentGuid); + int currentVisualIndex = displayPages.IndexOf(currentGuid); + return pagesByGuid[displayPages[forward ? NextVisualIndex() : PreviousVisualIndex()]]; + int NextVisualIndex() { - var naiveNextVisualIndex = currentVisualIndex + 1; + int naiveNextVisualIndex = currentVisualIndex + 1; return naiveNextVisualIndex >= displayPages.Count ? 0 : naiveNextVisualIndex; } + int PreviousVisualIndex() { - var naivePreviousVisualIndex = currentVisualIndex - 1; + int naivePreviousVisualIndex = currentVisualIndex - 1; return naivePreviousVisualIndex < 0 ? displayPages.Count - 1 : naivePreviousVisualIndex; } } @@ -193,11 +203,13 @@ public void ReorderPages(ProjectPage? page1, ProjectPage? page2) { } _ = this.RecordUndo(); - var index1 = displayPages.IndexOf(page1.guid); - var index2 = displayPages.IndexOf(page2.guid); + int index1 = displayPages.IndexOf(page1.guid); + int index2 = displayPages.IndexOf(page2.guid); + if (index1 == -1 || index2 == -1 || index1 == index2) { return; } + displayPages[index1] = page2.guid; displayPages[index2] = page1.guid; } @@ -213,26 +225,21 @@ public class ProjectSettings(Project project) : ModelObject(project) { public int reactorSizeY { get; set; } = 2; public float PollutionCostModifier { get; set; } = 0; public event Action? changed; - protected internal override void ThisChanged(bool visualOnly) { - changed?.Invoke(visualOnly); - } + protected internal override void ThisChanged(bool visualOnly) => changed?.Invoke(visualOnly); public void SetFlag(FactorioObject obj, ProjectPerItemFlags flag, bool set) { _ = itemFlags.TryGetValue(obj, out var flags); var newFlags = set ? flags | flag : flags & ~flag; + if (newFlags != flags) { _ = this.RecordUndo(); itemFlags[obj] = newFlags; } } - public ProjectPerItemFlags Flags(FactorioObject obj) { - return itemFlags.TryGetValue(obj, out var val) ? val : 0; - } + public ProjectPerItemFlags Flags(FactorioObject obj) => itemFlags.TryGetValue(obj, out var val) ? val : 0; - public float GetReactorBonusMultiplier() { - return 4f - (2f / reactorSizeX) - (2f / reactorSizeY); - } + public float GetReactorBonusMultiplier() => 4f - (2f / reactorSizeX) - (2f / reactorSizeY); } public class ProjectPreferences(Project owner) : ModelObject(owner) { @@ -257,23 +264,19 @@ protected internal override void AfterDeserialize() { defaultInserter ??= Database.allInserters.OrderBy(x => x.energy.type).ThenBy(x => 1f / x.inserterSwingTime).FirstOrDefault(); } - public (float multiplier, string suffix) GetTimeUnit() { - return time switch { - 1 or 0 => (1f, "s"), - 60 => (1f / 60f, "m"), - 3600 => (1f / 3600f, "h"), - _ => (1f / time, "t"), - }; - } + public (float multiplier, string suffix) GetTimeUnit() => time switch { + 1 or 0 => (1f, "s"), + 60 => (1f / 60f, "m"), + 3600 => (1f / 3600f, "h"), + _ => (1f / time, "t"), + }; - public (float multiplier, string suffix) GetPerTimeUnit() { - return time switch { - 1 or 0 => (1f, "/s"), - 60 => (60f, "/m"), - 3600 => (3600f, "/h"), - _ => (time, "/t"), - }; - } + public (float multiplier, string suffix) GetPerTimeUnit() => time switch { + 1 or 0 => (1f, "/s"), + 60 => (60f, "/m"), + 3600 => (3600f, "/h"), + _ => (time, "/t"), + }; public (float multiplier, string suffix) GetItemPerTimeUnit() { if (itemUnit == 0f) { @@ -307,6 +310,7 @@ protected internal override void ThisChanged(bool visualOnly) { public void ToggleFavorite(FactorioObject obj) { _ = this.RecordUndo(true); + if (favorites.Contains(obj)) { _ = favorites.Remove(obj); } diff --git a/Yafc.Model/Model/ProjectModuleTemplate.cs b/Yafc.Model/Model/ProjectModuleTemplate.cs index 9c1c0f54..986773ea 100644 --- a/Yafc.Model/Model/ProjectModuleTemplate.cs +++ b/Yafc.Model/Model/ProjectModuleTemplate.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; namespace Yafc.Model; + public class ProjectModuleTemplate : ModelObject { public ProjectModuleTemplate(Project owner, string name) : base(owner) { template = new ModuleTemplateBuilder().Build(this); diff --git a/Yafc.Model/Model/ProjectPage.cs b/Yafc.Model/Model/ProjectPage.cs index 809eaecb..180b48a2 100644 --- a/Yafc.Model/Model/ProjectPage.cs +++ b/Yafc.Model/Model/ProjectPage.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc.Model; + public class ProjectPage : ModelObject { public FactorioObject? icon { get; set; } public string name { get; set; } = "New page"; @@ -25,7 +26,8 @@ public ProjectPage(Project project, Type contentType, Guid guid = default) : bas this.guid = guid == default ? Guid.NewGuid() : guid; actualVersion = project.projectVersion; this.contentType = contentType; - content = Activator.CreateInstance(contentType, this) as ProjectPageContents ?? throw new ArgumentException($"{nameof(contentType)} must derive from {nameof(ProjectPageContents)}", nameof(contentType)); + content = Activator.CreateInstance(contentType, this) as ProjectPageContents + ?? throw new ArgumentException($"{nameof(contentType)} must derive from {nameof(ProjectPageContents)}", nameof(contentType)); } protected internal override void AfterDeserialize() { @@ -33,18 +35,14 @@ protected internal override void AfterDeserialize() { deleted = false; } - internal void MarkAsDeleted() { - deleted = true; - } + internal void MarkAsDeleted() => deleted = true; - public void GenerateNewGuid() { - guid = Guid.NewGuid(); - } + public void GenerateNewGuid() => guid = Guid.NewGuid(); public void SetActive(bool active) { this.active = active; if (active) { - CheckSolve(); + _ = CheckSolve(); } } @@ -54,14 +52,14 @@ public void SetToRecalculate() { currentSolvingVersion = 1; } else { - CheckSolve(); + _ = CheckSolve(); } } public void ContentChanged(bool visualOnly) { if (!visualOnly) { actualVersion = hierarchyVersion; - CheckSolve(); + _ = CheckSolve(); } contentChanged?.Invoke(visualOnly); } @@ -73,9 +71,7 @@ private Task CheckSolve() { return Task.CompletedTask; } - public bool IsSolutionStale() { - return content != null && actualVersion > lastSolvedVersion && currentSolvingVersion == 0; - } + public bool IsSolutionStale() => content != null && actualVersion > lastSolvedVersion && currentSolvingVersion == 0; protected internal override void ThisChanged(bool visualOnly) { // Don't propagate page changes to project diff --git a/Yafc.Model/Model/RecipeParameters.cs b/Yafc.Model/Model/RecipeParameters.cs index e1f9b25c..ddc4f1c0 100644 --- a/Yafc.Model/Model/RecipeParameters.cs +++ b/Yafc.Model/Model/RecipeParameters.cs @@ -2,6 +2,7 @@ using System.Linq; namespace Yafc.Model; + [Flags] public enum WarningFlags { // Non-errors @@ -77,6 +78,7 @@ public static RecipeParameters CalculateParameters(RecipeRow row) { } else { int temperature = fluid.temperature; + if (temperature > energy.workingTemperature.max) { temperature = energy.workingTemperature.max; warningFlags |= WarningFlags.FuelTemperatureExceedsMaximum; @@ -119,8 +121,10 @@ public static RecipeParameters CalculateParameters(RecipeRow row) { // Special case for boilers if (recipe.flags.HasFlags(RecipeFlags.UsesFluidTemperature)) { var fluid = recipe.ingredients[0].goods.fluid; + if (fluid != null) { float inputTemperature = fluid.temperature; + foreach (Fluid variant in row.variants.OfType()) { if (variant.originalName == fluid.originalName) { inputTemperature = variant.temperature; @@ -130,6 +134,7 @@ public static RecipeParameters CalculateParameters(RecipeRow row) { int outputTemp = recipe.products[0].goods.fluid!.temperature; // null-forgiving: UsesFluidTemperature tells us this is a special "Fluid boiling to ??°" recipe, with one output fluid. float deltaTemp = outputTemp - inputTemperature; float energyPerUnitOfFluid = deltaTemp * fluid.heatCapacity; + if (deltaTemp > 0 && fuel != null) { recipeTime = 60 * energyPerUnitOfFluid / (fuelUsagePerSecondPerBuilding * fuel.fuelValue * energy.effectivity); } @@ -138,6 +143,7 @@ public static RecipeParameters CalculateParameters(RecipeRow row) { bool isMining = recipe.flags.HasFlags(RecipeFlags.UsesMiningProductivity); activeEffects = new ModuleEffects(); + if (isMining) { productivity += Project.current.settings.miningProductivity; } @@ -155,6 +161,7 @@ public static RecipeParameters CalculateParameters(RecipeRow row) { } modules = default; + if (recipe.modules.Length > 0 && entity.allowedEffects != AllowedEffects.None) { row.GetModulesInfo((recipeTime, fuelUsagePerSecondPerBuilding), entity, ref activeEffects, ref modules); productivity += activeEffects.productivity; diff --git a/Yafc.Model/Model/Summary.cs b/Yafc.Model/Model/Summary.cs index eca5c31a..8a6b31f8 100644 --- a/Yafc.Model/Model/Summary.cs +++ b/Yafc.Model/Model/Summary.cs @@ -1,13 +1,10 @@ using System.Threading.Tasks; namespace Yafc.Model; -public class Summary : ProjectPageContents { - public bool showOnlyIssues { get; set; } +public class Summary(ModelObject page) : ProjectPageContents(page) { - public Summary(ModelObject page) : base(page) { } + public bool showOnlyIssues { get; set; } - public override Task Solve(ProjectPage page) { - return Task.FromResult(null); - } + public override Task Solve(ProjectPage page) => Task.FromResult(null); } diff --git a/Yafc.Model/Serialization/ErrorCollector.cs b/Yafc.Model/Serialization/ErrorCollector.cs index b4522ae1..460eff72 100644 --- a/Yafc.Model/Serialization/ErrorCollector.cs +++ b/Yafc.Model/Serialization/ErrorCollector.cs @@ -6,6 +6,7 @@ using Yafc.UI; namespace Yafc.Model; + public enum ErrorSeverity { None, AnalysisWarning, @@ -21,6 +22,7 @@ public class ErrorCollector { public ErrorSeverity severity { get; private set; } public void Error(string message, ErrorSeverity severity) { var key = (message, severity); + if (severity > this.severity) { this.severity = severity; } @@ -30,9 +32,8 @@ public void Error(string message, ErrorSeverity severity) { logger.Information(message); } - public (string error, ErrorSeverity severity)[] GetArrErrors() { - return allErrors.OrderByDescending(x => x.Key.severity).ThenByDescending(x => x.Value).Select(x => (x.Value == 1 ? x.Key.message : x.Key.message + " (x" + x.Value + ")", x.Key.severity)).ToArray(); - } + public (string error, ErrorSeverity severity)[] GetArrErrors() => allErrors.OrderByDescending(x => x.Key.severity).ThenByDescending(x => x.Value) + .Select(x => (x.Value == 1 ? x.Key.message : x.Key.message + " (x" + x.Value + ")", x.Key.severity)).ToArray(); public void Exception(Exception exception, string message, ErrorSeverity errorSeverity) { while (exception.InnerException != null) { @@ -40,6 +41,7 @@ public void Exception(Exception exception, string message, ErrorSeverity errorSe } string s = message + ": "; + if (exception is JsonException) { s += "unexpected or invalid json"; } diff --git a/Yafc.Model/Serialization/JsonUtils.cs b/Yafc.Model/Serialization/JsonUtils.cs index 91132b09..493418cc 100644 --- a/Yafc.Model/Serialization/JsonUtils.cs +++ b/Yafc.Model/Serialization/JsonUtils.cs @@ -4,11 +4,17 @@ using System.Text.Json; namespace Yafc.Model; + public static class JsonUtils { - public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = true, IgnoreReadOnlyProperties = true }; + public static readonly JsonSerializerOptions DefaultOptions = new JsonSerializerOptions { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, + IgnoreReadOnlyProperties = true + }; public static readonly JsonWriterOptions DefaultWriterOptions = new JsonWriterOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Indented = true }; internal static bool ReadStartArray(this ref Utf8JsonReader reader) { var token = reader.TokenType; + if (token == JsonTokenType.Null) { return false; } @@ -17,11 +23,13 @@ internal static bool ReadStartArray(this ref Utf8JsonReader reader) { _ = reader.Read(); return true; } + throw new JsonException("Expected array or null"); } internal static bool ReadStartObject(this ref Utf8JsonReader reader) { var token = reader.TokenType; + if (token == JsonTokenType.Null) { return false; } @@ -30,27 +38,32 @@ internal static bool ReadStartObject(this ref Utf8JsonReader reader) { _ = reader.Read(); return true; } + throw new JsonException("Expected object or null"); } public static T? Copy(T obj, ModelObject newOwner, ErrorCollector? collector) where T : ModelObject { using var ms = SaveToJson(obj); + return LoadFromJson(new ReadOnlySpan(ms.GetBuffer(), 0, (int)ms.Length), newOwner, collector); } public static MemoryStream SaveToJson(T obj) where T : ModelObject { MemoryStream ms = new MemoryStream(); + using (Utf8JsonWriter writer = new Utf8JsonWriter(ms)) { SerializationMap.SerializeToJson(obj, writer); } ms.Position = 0; + return ms; } public static T? LoadFromJson(MemoryStream stream, ModelObject owner, T? def = null) where T : ModelObject { ErrorCollector collector = new ErrorCollector(); var result = LoadFromJson(new ReadOnlySpan(stream.GetBuffer(), 0, (int)stream.Length), owner, collector, false); + if (collector.severity != ErrorSeverity.None) { return def; } @@ -63,6 +76,7 @@ public static MemoryStream SaveToJson(T obj) where T : ModelObject { _ = reader.Read(); DeserializationContext context = new DeserializationContext(collector); var result = SerializationMap.DeserializeFromJson(owner, ref reader, context); + if (notify) { context.Notify(); } diff --git a/Yafc.Model/Serialization/ModelObject.cs b/Yafc.Model/Serialization/ModelObject.cs index d6dfc7b3..4b4b4248 100644 --- a/Yafc.Model/Serialization/ModelObject.cs +++ b/Yafc.Model/Serialization/ModelObject.cs @@ -1,6 +1,7 @@ using System; namespace Yafc.Model; + /* * Base class for objects that can be serialized to JSON and that support undo * supports ONLY properties of following types: @@ -23,9 +24,7 @@ internal ModelObject(UndoSystem undo) { } [SkipSerialization] public abstract ModelObject? ownerObject { get; internal set; } - public ModelObject GetRoot() { - return ownerObject?.GetRoot() ?? this; - } + public ModelObject GetRoot() => ownerObject?.GetRoot() ?? this; private uint _objectVersion; private uint _hierarchyVersion; @@ -53,17 +52,11 @@ private set { } protected internal virtual void AfterDeserialize() { } - protected internal virtual void ThisChanged(bool visualOnly) { - ownerObject?.ThisChanged(visualOnly); - } + protected internal virtual void ThisChanged(bool visualOnly) => ownerObject?.ThisChanged(visualOnly); - internal SerializationMap GetUndoBuilder() { - return SerializationMap.GetSerializationMap(GetType()); - } + internal SerializationMap GetUndoBuilder() => SerializationMap.GetSerializationMap(GetType()); - internal void CreateUndoSnapshot(bool visualOnly = false) { - undo?.CreateUndoSnapshot(this, visualOnly); - } + internal void CreateUndoSnapshot(bool visualOnly = false) => undo?.CreateUndoSnapshot(this, visualOnly); private protected virtual void WriteExtraUndoInformation(UndoSnapshotBuilder builder) { } private protected virtual void ReadExtraUndoInformation(UndoSnapshotReader reader) { } diff --git a/Yafc.Model/Serialization/PropertySerializers.cs b/Yafc.Model/Serialization/PropertySerializers.cs index dd33a77a..38410cc1 100644 --- a/Yafc.Model/Serialization/PropertySerializers.cs +++ b/Yafc.Model/Serialization/PropertySerializers.cs @@ -7,6 +7,7 @@ using System.Text.Json.Serialization; namespace Yafc.Model; + internal enum PropertyType { Normal, Immutable, @@ -22,6 +23,7 @@ internal abstract class PropertySerializer where TOwner : class { protected PropertySerializer(PropertyInfo property, PropertyType type, bool usingSetter) { this.property = property; this.type = type; + if (property.GetCustomAttribute() != null) { this.type = PropertyType.Obsolete; } @@ -43,8 +45,7 @@ protected PropertySerializer(PropertyInfo property, PropertyType type, bool usin public abstract void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader, DeserializationContext context); public abstract void SerializeToUndoBuilder(TOwner owner, UndoSnapshotBuilder builder); public abstract void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader reader); - public virtual object? DeserializeFromJson(ref Utf8JsonReader reader, DeserializationContext context) => - throw new NotSupportedException(); + public virtual object? DeserializeFromJson(ref Utf8JsonReader reader, DeserializationContext context) => throw new NotSupportedException(); public virtual bool CanBeNull => false; } @@ -68,16 +69,18 @@ internal sealed class ValuePropertySerializer(PropertyInf private static readonly ValueSerializer ValueSerializer = ValueSerializer.Default; - private void setter(TOwner owner, TPropertyType? value) - // TODO (yafc-ce/issues/256): unwrap this one-liner - => _setter(owner ?? throw new ArgumentNullException(nameof(owner)), - value ?? (CanBeNull ? default : - throw new InvalidOperationException($"{property.DeclaringType}.{propertyName} must not be set to null."))); + private void setter(TOwner owner, TPropertyType? value) => _setter(NullCheckOwner(owner), NullCheckValue(value)); + + private static TOwner NullCheckOwner(TOwner owner) => owner ?? throw new ArgumentNullException(nameof(owner)); + + private TPropertyType? NullCheckValue(TPropertyType? value) + => value ?? (CanBeNull ? default : throw new InvalidOperationException($"{property.DeclaringType}.{propertyName} must not be set to null.")); private new TPropertyType? getter(TOwner owner) { ArgumentNullException.ThrowIfNull(owner, nameof(owner)); var result = _getter(owner); + if (result == null) { if (CanBeNull) { return default; @@ -90,7 +93,6 @@ private void setter(TOwner owner, TPropertyType? value) return result; } - public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) => ValueSerializer.WriteToJson(writer, getter(owner)); @@ -121,6 +123,7 @@ public ReadOnlyReferenceSerializer(PropertyInfo property, PropertyType type, boo public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) { var instance = getter(owner); + if (instance == null) { writer.WriteNullValue(); } @@ -138,6 +141,7 @@ public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader } var instance = getter(owner); + if (instance == null) { context.Error("Project contained an unexpected object", ErrorSeverity.MinorDataLoss); reader.Skip(); @@ -170,6 +174,7 @@ public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader } var instance = getter(owner); + if (instance == null) { setter(owner, SerializationMap.DeserializeFromJson(owner, ref reader, context)); return; @@ -194,6 +199,7 @@ internal sealed class CollectionSerializer(Proper public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) { TCollection list = getter(owner); writer.WriteStartArray(); + foreach (var elem in list) { ValueSerializer.WriteToJson(writer, elem); } @@ -204,6 +210,7 @@ public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) { public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader, DeserializationContext context) { TCollection list = getter(owner); list.Clear(); + if (reader.ReadStartArray()) { while (reader.TokenType != JsonTokenType.EndArray) { var item = ValueSerializer.ReadFromJson(ref reader, context, owner); @@ -219,6 +226,7 @@ public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader public override void SerializeToUndoBuilder(TOwner owner, UndoSnapshotBuilder builder) { TCollection list = getter(owner); builder.writer.Write(list.Count); + foreach (var elem in list) { ValueSerializer.WriteToUndoSnapshot(builder, elem); } @@ -228,6 +236,7 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader TCollection list = getter(owner); list.Clear(); int count = reader.reader.ReadInt32(); + for (int i = 0; i < count; i++) { list.Add(ValueSerializer.ReadFromUndoSnapshot(reader, owner)); } @@ -244,6 +253,7 @@ internal sealed class ReadOnlyCollectionSerializer(PropertyI public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) { TCollection? dictionary = getter(owner); writer.WriteStartObject(); - foreach (var (key, value) in dictionary.Select(x => (Key: KeySerializer.GetJsonProperty(x.Key), x.Value)) - .OrderBy(x => x.Key, StringComparer.Ordinal)) { + + foreach (var (key, value) in dictionary.Select(x => (Key: KeySerializer.GetJsonProperty(x.Key), x.Value)).OrderBy(x => x.Key, StringComparer.Ordinal)) { writer.WritePropertyName(key); ValueSerializer.WriteToJson(writer, value); @@ -313,12 +325,14 @@ public override void SerializeToJson(TOwner owner, Utf8JsonWriter writer) { public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader, DeserializationContext context) { TCollection? dictionary = getter(owner); dictionary.Clear(); + if (reader.ReadStartObject()) { while (reader.TokenType != JsonTokenType.EndObject) { var key = KeySerializer.ReadFromJsonProperty(ref reader, context, owner); _ = reader.Read(); var value = ValueSerializer.ReadFromJson(ref reader, context, owner); _ = reader.Read(); + if (key != null && value != null) { dictionary.Add(key, value); } @@ -329,6 +343,7 @@ public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader public override void SerializeToUndoBuilder(TOwner owner, UndoSnapshotBuilder builder) { TCollection? dictionary = getter(owner); builder.writer.Write(dictionary.Count); + foreach (var elem in dictionary) { KeySerializer.WriteToUndoSnapshot(builder, elem.Key); ValueSerializer.WriteToUndoSnapshot(builder, elem.Value); @@ -339,11 +354,11 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader TCollection? dictionary = getter(owner); dictionary.Clear(); int count = reader.reader.ReadInt32(); + for (int i = 0; i < count; i++) { TKey key = KeySerializer.ReadFromUndoSnapshot(reader, owner) ?? throw new InvalidOperationException($"Serialized a null key for {property}. Cannot deserialize undo entry."); - dictionary.Add(key, DictionarySerializer.ValueSerializer - .ReadFromUndoSnapshot(reader, owner)); + dictionary.Add(key, DictionarySerializer.ValueSerializer.ReadFromUndoSnapshot(reader, owner)); } } } diff --git a/Yafc.Model/Serialization/SerializationMap.cs b/Yafc.Model/Serialization/SerializationMap.cs index 79f07683..baf7e1fe 100644 --- a/Yafc.Model/Serialization/SerializationMap.cs +++ b/Yafc.Model/Serialization/SerializationMap.cs @@ -8,6 +8,7 @@ using Yafc.UI; namespace Yafc.Model; + [AttributeUsage(AttributeTargets.Property)] public class SkipSerializationAttribute : Attribute { } @@ -22,6 +23,7 @@ internal abstract class SerializationMap { public UndoSnapshot MakeUndoSnapshot(ModelObject target) { UndoSnapshotBuilder snapshotBuilder = new(target); BuildUndo(target, snapshotBuilder); + return snapshotBuilder.Build(); } public void RevertToUndoSnapshot(ModelObject target, UndoSnapshot snapshot) { @@ -31,7 +33,6 @@ public void RevertToUndoSnapshot(ModelObject target, UndoSnapshot snapshot) { public abstract void BuildUndo(object target, UndoSnapshotBuilder builder); public abstract void ReadUndo(object target, UndoSnapshotReader reader); - private static readonly Dictionary undoBuilders = []; protected static int deserializingCount; public static bool IsDeserializing => deserializingCount > 0; @@ -60,6 +61,7 @@ internal static class SerializationMap where T : class { public class SpecificSerializationMap : SerializationMap { public override void BuildUndo(object target, UndoSnapshotBuilder builder) { T t = (T)target; + foreach (var property in properties) { if (property.type == PropertyType.Normal) { property.SerializeToUndoBuilder(t, builder); @@ -71,6 +73,7 @@ public override void ReadUndo(object target, UndoSnapshotReader reader) { try { deserializingCount++; T t = (T)target; + foreach (var property in properties) { if (property.type == PropertyType.Normal) { property.DeserializeFromUndoBuilder(t, reader); @@ -82,9 +85,7 @@ public override void ReadUndo(object target, UndoSnapshotReader reader) { } } - public override void SerializeToJson(object target, Utf8JsonWriter writer) { - SerializationMap.SerializeToJson((T)target, writer); - } + public override void SerializeToJson(object target, Utf8JsonWriter writer) => SerializationMap.SerializeToJson((T)target, writer); public override void PopulateFromJson(object target, ref Utf8JsonReader reader, DeserializationContext context) { try { @@ -100,22 +101,28 @@ public override void PopulateFromJson(object target, ref Utf8JsonReader reader, private static bool GetInterfaceSerializer(Type iface, [MaybeNullWhen(false)] out Type serializerType, out Type? keyType, [MaybeNullWhen(false)] out Type elementType) { if (iface.IsGenericType) { var definition = iface.GetGenericTypeDefinition(); + if (definition == typeof(ICollection<>)) { elementType = iface.GetGenericArguments()[0]; keyType = null; + if (ValueSerializer.IsValueSerializerSupported(elementType)) { serializerType = typeof(CollectionSerializer<,,>); + return true; } } if (definition == typeof(IDictionary<,>)) { var args = iface.GetGenericArguments(); + if (ValueSerializer.IsValueSerializerSupported(args[0])) { keyType = args[0]; elementType = args[1]; + if (ValueSerializer.IsValueSerializerSupported(elementType)) { serializerType = typeof(DictionarySerializer<,,,>); + return true; } } @@ -123,25 +130,30 @@ private static bool GetInterfaceSerializer(Type iface, [MaybeNullWhen(false)] ou } keyType = elementType = serializerType = null; + return false; } static SerializationMap() { List> list = []; - bool isModel = typeof(ModelObject).IsAssignableFrom(typeof(T)); + if (typeof(T).GetCustomAttribute() != null) { constructor = typeof(T).GetConstructors(BindingFlags.NonPublic | BindingFlags.Instance)[0]; } else { constructor = typeof(T).GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0]; } + var constructorParameters = constructor.GetParameters(); List processedProperties = []; + if (constructorParameters.Length > 0) { int firstReadOnlyArg = 0; + if (isModel) { parentType = constructorParameters[0].ParameterType; + if (!typeof(ModelObject).IsAssignableFrom(parentType)) { throw new NotSupportedException("First parameter of constructor of type " + typeof(T) + " should be 'parent'"); } @@ -150,6 +162,7 @@ static SerializationMap() { } for (int i = firstReadOnlyArg; i < constructorParameters.Length; i++) { var argument = constructorParameters[i]; + if (!ValueSerializer.IsValueSerializerSupported(argument.ParameterType)) { throw new NotSupportedException("Constructor of type " + typeof(T) + " parameter " + argument.Name + " should be value"); } @@ -159,6 +172,7 @@ static SerializationMap() { PropertySerializer serializer = (PropertySerializer)Activator.CreateInstance(typeof(ValuePropertySerializer<,>).MakeGenericType(typeof(T), argument.ParameterType), property)!; list.Add(serializer); constructorFieldMask |= 1ul << (i - firstReadOnlyArg); + if (!argument.IsOptional) { requiredConstructorFieldMask |= 1ul << (i - firstReadOnlyArg); } @@ -178,6 +192,7 @@ static SerializationMap() { var propertyType = property.PropertyType; Type? serializerType = null, elementType = null, keyType = null; + if (property.CanWrite && property.GetSetMethod() != null) { if (ValueSerializer.IsValueSerializerSupported(propertyType)) { serializerType = typeof(ValuePropertySerializer<,>); @@ -207,11 +222,11 @@ static SerializationMap() { } if (serializerType != null) { - var typeArgs = elementType == null ? new[] { typeof(T), propertyType } : keyType == null ? new[] { typeof(T), propertyType, elementType } : new[] { typeof(T), propertyType, keyType, elementType }; + Type[] typeArgs = elementType == null ? [typeof(T), propertyType] : keyType == null ? [typeof(T), propertyType, elementType] : [typeof(T), propertyType, keyType, elementType]; list.Add((PropertySerializer)Activator.CreateInstance(serializerType.MakeGenericType(typeArgs), property)!); } } - properties = list.ToArray(); + properties = [.. list]; } public static void SerializeToJson(T? value, Utf8JsonWriter writer) { @@ -219,7 +234,9 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { writer.WriteNullValue(); return; } + writer.WriteStartObject(); + foreach (var property in properties) { if (property.type == PropertyType.Obsolete) { continue; @@ -265,6 +282,7 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { int depth = reader.CurrentDepth; try { T obj; + if (parentType != null || constructorProperties > 0) { if (parentType != null && !parentType.IsInstanceOfType(owner)) { throw new NotSupportedException("Parent is of wrong type"); @@ -273,13 +291,16 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { int firstReadOnlyArg = parentType == null ? 0 : 1; object?[] constructorArgs = new object[constructorProperties + firstReadOnlyArg]; constructorArgs[0] = owner; + if (constructorProperties > 0) { var savedReaderState = reader; int lastMatch = -1; ulong constructorMissingFields = constructorFieldMask; _ = reader.Read(); // StartObject + while (constructorMissingFields != 0 && reader.TokenType != JsonTokenType.EndObject) { var property = FindProperty(ref reader, ref lastMatch); + if (property != null && lastMatch < constructorProperties) { _ = reader.Read(); // PropertyName constructorMissingFields &= ~(1ul << lastMatch); @@ -288,6 +309,7 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { else { reader.Skip(); } + _ = reader.Read(); // Property value (String, Number, True, False, Null) or end of property value (EndObject, EndArray) } @@ -305,10 +327,12 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { } PopulateFromJson(obj, ref reader, context); + return obj; } catch (Exception ex) { context.Exception(ex, "Unable to deserialize " + typeof(T).Name, ErrorSeverity.MajorDataLoss); + if (reader.TokenType == JsonTokenType.StartObject && reader.CurrentDepth == depth) { _ = reader.Read(); } @@ -332,8 +356,10 @@ public static void PopulateFromJson(T obj, ref Utf8JsonReader reader, Deserializ int lastMatch = -1; _ = reader.Read(); + while (reader.TokenType != JsonTokenType.EndObject) { var property = FindProperty(ref reader, ref lastMatch); + if (property == null || lastMatch < constructorProperties) { if (property == null) { logger.Error("Json has extra property: " + reader.GetString()); @@ -355,17 +381,10 @@ public static void PopulateFromJson(T obj, ref Utf8JsonReader reader, Deserializ } } -public class DeserializationContext { +public class DeserializationContext(ErrorCollector? errorCollector) { private readonly List allObjects = []; - private readonly ErrorCollector? collector; - public DeserializationContext(ErrorCollector? errorCollector) { - collector = errorCollector; - } - - public void Add(ModelObject obj) { - allObjects.Add(obj); - } + public void Add(ModelObject obj) => allObjects.Add(obj); public void Notify() { foreach (var o in allObjects) { @@ -377,11 +396,7 @@ public void Notify() { } } - public void Error(string message, ErrorSeverity severity) { - collector?.Error(message, severity); - } + public void Error(string message, ErrorSeverity severity) => errorCollector?.Error(message, severity); - public void Exception(Exception exception, string message, ErrorSeverity severity) { - collector?.Exception(exception, message, severity); - } + public void Exception(Exception exception, string message, ErrorSeverity severity) => errorCollector?.Exception(exception, message, severity); } diff --git a/Yafc.Model/Serialization/UndoSystem.cs b/Yafc.Model/Serialization/UndoSystem.cs index 88f8d7a9..3aed6205 100644 --- a/Yafc.Model/Serialization/UndoSystem.cs +++ b/Yafc.Model/Serialization/UndoSystem.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc.Model; + public class UndoSystem { public uint version { get; private set; } = 2; private bool undoBatchVisualOnly = true; @@ -17,12 +18,15 @@ internal void CreateUndoSnapshot(ModelObject target, bool visualOnly) { if (SerializationMap.IsDeserializing) { throw new InvalidOperationException("Do not record an undo event while deserializing."); } + if (changedList.Count == 0) { version++; + if (!suspended && !scheduled) { Schedule(); } } + undoBatchVisualOnly &= visualOnly; if (target.objectVersion == version) { @@ -31,6 +35,7 @@ internal void CreateUndoSnapshot(ModelObject target, bool visualOnly) { changedList.Add(target); target.objectVersion = version; + if (visualOnly && undo.Count > 0 && undo.Peek().Contains(target)) { return; } @@ -43,16 +48,18 @@ private static void MakeUndoBatch(object? state) { UndoSystem system = (UndoSystem)state!; // null-forgiving: Only called by the instance method Schedule, which passes its this. system.scheduled = false; bool visualOnly = system.undoBatchVisualOnly; + for (int i = 0; i < system.changedList.Count; i++) { system.changedList[i].ThisChanged(visualOnly); } system.changedList.Clear(); + if (system.currentUndoBatch.Count == 0) { return; } - UndoBatch batch = new UndoBatch(system.currentUndoBatch.ToArray(), visualOnly); + UndoBatch batch = new UndoBatch([.. system.currentUndoBatch], visualOnly); system.undo.Push(batch); system.undoBatchVisualOnly = true; system.redo.Clear(); @@ -64,12 +71,11 @@ private void Schedule() { scheduled = true; } - public void Suspend() { - suspended = true; - } + public void Suspend() => suspended = true; public void Resume() { suspended = false; + if (!scheduled && changedList.Count > 0) { Schedule(); } @@ -91,24 +97,14 @@ public void PerformRedo() { undo.Push(redo.Pop().Restore(++version)); } - public void RecordChange() { - ++version; - } + public void RecordChange() => version++; - public bool HasChangesPending(ModelObject obj) { - return changedList.Contains(obj); - } + public bool HasChangesPending(ModelObject obj) => changedList.Contains(obj); } -internal readonly struct UndoSnapshot { - internal readonly ModelObject target; - internal readonly object?[]? managedReferences; - internal readonly byte[]? unmanagedData; - - public UndoSnapshot(ModelObject target, object?[]? managed, byte[]? unmanaged) { - this.target = target; - managedReferences = managed; - unmanagedData = unmanaged; - } +internal readonly struct UndoSnapshot(ModelObject target, object?[]? managed, byte[]? unmanaged) { + internal readonly ModelObject target = target; + internal readonly object?[]? managedReferences = managed; + internal readonly byte[]? unmanagedData = unmanaged; public UndoSnapshot Restore() { var builder = target.GetUndoBuilder(); @@ -118,18 +114,16 @@ public UndoSnapshot Restore() { } } -internal readonly struct UndoBatch { - public readonly UndoSnapshot[] snapshots; - public readonly bool visualOnly; - public UndoBatch(UndoSnapshot[] snapshots, bool visualOnly) { - this.snapshots = snapshots; - this.visualOnly = visualOnly; - } +internal readonly struct UndoBatch(UndoSnapshot[] snapshots, bool visualOnly) { + public readonly UndoSnapshot[] snapshots = snapshots; + public readonly bool visualOnly = visualOnly; + public UndoBatch Restore(uint undoState) { for (int i = 0; i < snapshots.Length; i++) { snapshots[i] = snapshots[i].Restore(); snapshots[i].target.objectVersion = undoState; } + foreach (var snapshot in snapshots) { snapshot.target.AfterDeserialize(); } @@ -147,6 +141,7 @@ public bool Contains(ModelObject target) { return true; } } + return false; } } @@ -164,23 +159,22 @@ internal UndoSnapshotBuilder(ModelObject target) { internal UndoSnapshot Build() { byte[]? buffer = null; + if (stream.Position > 0) { buffer = new byte[stream.Position]; Array.Copy(stream.GetBuffer(), buffer, stream.Position); } - UndoSnapshot result = new UndoSnapshot(currentTarget, managedRefs.Count > 0 ? managedRefs.ToArray() : null, buffer); + + UndoSnapshot result = new UndoSnapshot(currentTarget, managedRefs.Count > 0 ? [.. managedRefs] : null, buffer); stream.Position = 0; managedRefs.Clear(); + return result; } - public void WriteManagedReference(object? reference) { - managedRefs.Add(reference); - } + public void WriteManagedReference(object? reference) => managedRefs.Add(reference); - public void WriteManagedReferences(IEnumerable references) { - managedRefs.AddRange(references); - } + public void WriteManagedReferences(IEnumerable references) => managedRefs.AddRange(references); } internal class UndoSnapshotReader { @@ -206,6 +200,7 @@ internal UndoSnapshotReader(UndoSnapshot snapshot) { if (managed == null) { throw new InvalidOperationException("No managed objects are available to read in this undo snapshot."); } + return managed[refId++]; } diff --git a/Yafc.Model/Serialization/ValueSerializers.cs b/Yafc.Model/Serialization/ValueSerializers.cs index 50e8eb2e..a0f583f4 100644 --- a/Yafc.Model/Serialization/ValueSerializers.cs +++ b/Yafc.Model/Serialization/ValueSerializers.cs @@ -4,9 +4,12 @@ using System.Text.Json; namespace Yafc.Model; + internal static class ValueSerializer { public static bool IsValueSerializerSupported(Type type) { - if (type == typeof(int) || type == typeof(float) || type == typeof(bool) || type == typeof(ulong) || type == typeof(string) || type == typeof(Type) || type == typeof(Guid) || type == typeof(PageReference)) { + if (type == typeof(int) || type == typeof(float) || type == typeof(bool) || type == typeof(ulong) || type == typeof(string) + || type == typeof(Type) || type == typeof(Guid) || type == typeof(PageReference)) { + return true; } @@ -91,13 +94,9 @@ private static object CreateValueSerializer() { public abstract T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner); public abstract void WriteToJson(Utf8JsonWriter writer, T? value); - public virtual string GetJsonProperty(T value) { - throw new NotSupportedException("Using type " + typeof(T) + " as dictionary key is not supported"); - } + public virtual string GetJsonProperty(T value) => throw new NotSupportedException("Using type " + typeof(T) + " as dictionary key is not supported"); - public virtual T? ReadFromJsonProperty(ref Utf8JsonReader reader, DeserializationContext context, object owner) { - return ReadFromJson(ref reader, context, owner); - } + public virtual T? ReadFromJsonProperty(ref Utf8JsonReader reader, DeserializationContext context, object owner) => ReadFromJson(ref reader, context, owner); public abstract T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner); public abstract void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value); @@ -105,21 +104,14 @@ public virtual string GetJsonProperty(T value) { } internal class ModelObjectSerializer : ValueSerializer where T : ModelObject { - public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return SerializationMap.DeserializeFromJson((ModelObject?)owner, ref reader, context); - } + public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) + => SerializationMap.DeserializeFromJson((ModelObject?)owner, ref reader, context); - public override void WriteToJson(Utf8JsonWriter writer, T? value) { - SerializationMap.SerializeToJson(value, writer); - } + public override void WriteToJson(Utf8JsonWriter writer, T? value) => SerializationMap.SerializeToJson(value, writer); - public override T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.ReadOwnedReference((ModelObject)owner); - } + public override T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadOwnedReference((ModelObject)owner); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value) { - writer.WriteManagedReference(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value) => writer.WriteManagedReference(value); public override bool CanBeNull => true; } @@ -162,61 +154,36 @@ public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value) { } internal class IntSerializer : ValueSerializer { - public override int ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return reader.GetInt32(); - } + public override int ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => reader.GetInt32(); - public override void WriteToJson(Utf8JsonWriter writer, int value) { - writer.WriteNumberValue(value); - } + public override void WriteToJson(Utf8JsonWriter writer, int value) => writer.WriteNumberValue(value); - public override int ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.reader.ReadInt32(); - } + public override int ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.reader.ReadInt32(); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, int value) { - writer.writer.Write(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, int value) => writer.writer.Write(value); } internal class FloatSerializer : ValueSerializer { - public override float ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return reader.GetSingle(); - } + public override float ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => reader.GetSingle(); - public override void WriteToJson(Utf8JsonWriter writer, float value) { - writer.WriteNumberValue(value); - } + public override void WriteToJson(Utf8JsonWriter writer, float value) => writer.WriteNumberValue(value); - public override float ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.reader.ReadSingle(); - } + public override float ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.reader.ReadSingle(); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, float value) { - writer.writer.Write(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, float value) => writer.writer.Write(value); } internal class GuidSerializer : ValueSerializer { - public override Guid ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return new Guid(reader.GetString()!); // null-forgiving: If reader.GetString() returns null, we don't have a good backup and we'll find out immediately - } + // null-forgiving: If reader.GetString() returns null, we don't have a good backup and we'll find out immediately + public override Guid ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => new Guid(reader.GetString()!); - public override void WriteToJson(Utf8JsonWriter writer, Guid value) { - writer.WriteStringValue(value.ToString("N")); - } + public override void WriteToJson(Utf8JsonWriter writer, Guid value) => writer.WriteStringValue(value.ToString("N")); - public override string GetJsonProperty(Guid value) { - return value.ToString("N"); - } + public override string GetJsonProperty(Guid value) => value.ToString("N"); - public override Guid ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return new Guid(reader.reader.ReadBytes(16)); - } + public override Guid ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => new Guid(reader.reader.ReadBytes(16)); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, Guid value) { - writer.writer.Write(value.ToByteArray()); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, Guid value) => writer.writer.Write(value.ToByteArray()); } internal class PageReferenceSerializer : ValueSerializer { @@ -238,13 +205,9 @@ public override void WriteToJson(Utf8JsonWriter writer, PageReference? value) { } } - public override PageReference? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.ReadManagedReference() as PageReference; - } + public override PageReference? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadManagedReference() as PageReference; - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, PageReference? value) { - writer.WriteManagedReference(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, PageReference? value) => writer.WriteManagedReference(value); public override bool CanBeNull => true; } @@ -252,6 +215,7 @@ public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, PageReferen internal class TypeSerializer : ValueSerializer { public override Type? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { string? s = reader.GetString(); + if (s == null) { return null; } @@ -269,6 +233,7 @@ internal class TypeSerializer : ValueSerializer { public override void WriteToJson(Utf8JsonWriter writer, Type? value) { ArgumentNullException.ThrowIfNull(value, nameof(value)); string? name = value.FullName; + // TODO: Once no one will want to roll back to 0.7.2 or earlier, remove this if block. if (name?.StartsWith("Yafc.") ?? false) { name = "YAFC." + name[5..]; @@ -283,6 +248,7 @@ public override string GetJsonProperty(Type value) { } string name = value.FullName; + // TODO: Once no one will want to roll back to 0.7.2 or earlier, remove this if block. if (name.StartsWith("Yafc.")) { name = "YAFC." + name[5..]; @@ -290,71 +256,41 @@ public override string GetJsonProperty(Type value) { return name; } - public override Type? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.ReadManagedReference() as Type; - } + public override Type? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadManagedReference() as Type; - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, Type? value) { - writer.WriteManagedReference(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, Type? value) => writer.WriteManagedReference(value); } internal class BoolSerializer : ValueSerializer { - public override bool ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return reader.GetBoolean(); - } + public override bool ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => reader.GetBoolean(); - public override void WriteToJson(Utf8JsonWriter writer, bool value) { - writer.WriteBooleanValue(value); - } + public override void WriteToJson(Utf8JsonWriter writer, bool value) => writer.WriteBooleanValue(value); - public override bool ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.reader.ReadBoolean(); - } + public override bool ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.reader.ReadBoolean(); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, bool value) { - writer.writer.Write(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, bool value) => writer.writer.Write(value); } internal class ULongSerializer : ValueSerializer { - public override ulong ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return reader.GetUInt64(); - } + public override ulong ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => reader.GetUInt64(); - public override void WriteToJson(Utf8JsonWriter writer, ulong value) { - writer.WriteNumberValue(value); - } + public override void WriteToJson(Utf8JsonWriter writer, ulong value) => writer.WriteNumberValue(value); - public override ulong ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.reader.ReadUInt64(); - } + public override ulong ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.reader.ReadUInt64(); - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, ulong value) { - writer.writer.Write(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, ulong value) => writer.writer.Write(value); } internal class StringSerializer : ValueSerializer { - public override string? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return reader.GetString(); - } + public override string? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => reader.GetString(); - public override void WriteToJson(Utf8JsonWriter writer, string? value) { - writer.WriteStringValue(value); - } + public override void WriteToJson(Utf8JsonWriter writer, string? value) => writer.WriteStringValue(value); - public override string GetJsonProperty(string value) { - return value; - } + public override string GetJsonProperty(string value) => value; - public override string? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.ReadManagedReference() as string; - } + public override string? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadManagedReference() as string; - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, string? value) { - writer.WriteManagedReference(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, string? value) => writer.WriteManagedReference(value); public override bool CanBeNull => true; } @@ -362,16 +298,19 @@ public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, string? val internal class FactorioObjectSerializer : ValueSerializer where T : FactorioObject { public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { string? s = reader.GetString(); + if (s == null) { return null; } if (!Database.objectsByTypeName.TryGetValue(s, out var obj)) { var substitute = Database.FindClosestVariant(s); + if (substitute is T t) { context.Error("Fluid " + t.locName + " doesn't have correct temperature information. May require adjusting its temperature.", ErrorSeverity.MinorDataLoss); return t; } + context.Error("Factorio object '" + s + "' no longer exist. Check mods configuration.", ErrorSeverity.MinorDataLoss); } return obj as T; @@ -385,17 +324,11 @@ public override void WriteToJson(Utf8JsonWriter writer, T? value) { writer.WriteStringValue(value.typeDotName); } } - public override string GetJsonProperty(T value) { - return value.typeDotName; - } + public override string GetJsonProperty(T value) => value.typeDotName; - public override T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { - return reader.ReadManagedReference() as T; - } + public override T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadManagedReference() as T; - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value) { - writer.WriteManagedReference(value); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value) => writer.WriteManagedReference(value); public override bool CanBeNull => true; } @@ -412,29 +345,21 @@ public override T ReadFromJson(ref Utf8JsonReader reader, DeserializationContext return Unsafe.As(ref val); } - public override void WriteToJson(Utf8JsonWriter writer, T value) { - writer.WriteNumberValue(Unsafe.As(ref value)); - } + public override void WriteToJson(Utf8JsonWriter writer, T value) => writer.WriteNumberValue(Unsafe.As(ref value)); public override T ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { int val = reader.reader.ReadInt32(); return Unsafe.As(ref val); } - public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T value) { - writer.writer.Write(Unsafe.As(ref value)); - } + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T value) => writer.writer.Write(Unsafe.As(ref value)); } internal class PlainClassesSerializer : ValueSerializer where T : class { private static readonly SerializationMap builder = SerializationMap.GetSerializationMap(typeof(T)); - public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { - return SerializationMap.DeserializeFromJson(null, ref reader, context); - } + public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => SerializationMap.DeserializeFromJson(null, ref reader, context); - public override void WriteToJson(Utf8JsonWriter writer, T? value) { - SerializationMap.SerializeToJson(value, writer); - } + public override void WriteToJson(Utf8JsonWriter writer, T? value) => SerializationMap.SerializeToJson(value, writer); public override T ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) { T obj = (T?)reader.ReadManagedReference() ?? throw new InvalidOperationException("Read an unexpected null value from the undo snapshot; cannot undo."); diff --git a/Yafc.Parser/Data/DataParserUtils.cs b/Yafc.Parser/Data/DataParserUtils.cs index ba25f47c..c1971c51 100644 --- a/Yafc.Parser/Data/DataParserUtils.cs +++ b/Yafc.Parser/Data/DataParserUtils.cs @@ -4,6 +4,7 @@ using System.Linq; namespace Yafc.Parser; + internal static class DataParserUtils { private static class ConvertersFromLua { public static Func? convert; @@ -13,6 +14,7 @@ static DataParserUtils() { ConvertersFromLua.convert = (o, def) => o is long l ? (int)l : o is double d ? (int)d : o is string s && int.TryParse(s, out int res) ? res : def; ConvertersFromLua.convert = (o, def) => o is long l ? l : o is double d ? (float)d : o is string s && float.TryParse(s, out float res) ? res : def; ConvertersFromLua.convert = delegate (object src, bool def) { + if (src is bool b) { return b; } @@ -53,25 +55,15 @@ private static bool Parse(object? value, out T result, T def) { return true; } - private static bool Parse(object? value, [MaybeNullWhen(false)] out T result) { - return Parse(value, out result, default!); // null-forgiving: The three-argument Parse takes a non-null default to guarantee a non-null result. We don't make that guarantee. - } + private static bool Parse(object? value, [MaybeNullWhen(false)] out T result) => Parse(value, out result, default!); // null-forgiving: The three-argument Parse takes a non-null default to guarantee a non-null result. We don't make that guarantee. - public static bool Get(this LuaTable? table, string key, out T result, T def) { - return Parse(table?[key], out result, def); - } + public static bool Get(this LuaTable? table, string key, out T result, T def) => Parse(table?[key], out result, def); - public static bool Get(this LuaTable? table, int key, out T result, T def) { - return Parse(table?[key], out result, def); - } + public static bool Get(this LuaTable? table, int key, out T result, T def) => Parse(table?[key], out result, def); - public static bool Get(this LuaTable? table, string key, [NotNullWhen(true)] out T? result) { - return Parse(table?[key], out result); - } + public static bool Get(this LuaTable? table, string key, [NotNullWhen(true)] out T? result) => Parse(table?[key], out result); - public static bool Get(this LuaTable? table, int key, [NotNullWhen(true)] out T? result) { - return Parse(table?[key], out result); - } + public static bool Get(this LuaTable? table, int key, [NotNullWhen(true)] out T? result) => Parse(table?[key], out result); public static T Get(this LuaTable? table, string key, T def) { _ = Parse(table?[key], out var result, def); @@ -93,13 +85,7 @@ public static T Get(this LuaTable table, int key, T def) { return result; } - public static T[] SingleElementArray(this T item) { - return [item]; - } - - public static IEnumerable ArrayElements(this LuaTable? table) { - return table?.ArrayElements.OfType() ?? []; - } + public static IEnumerable ArrayElements(this LuaTable? table) => table?.ArrayElements.OfType() ?? []; } public static class SpecialNames { diff --git a/Yafc.Parser/Data/FactorioDataDeserializer.cs b/Yafc.Parser/Data/FactorioDataDeserializer.cs index f57624b3..a8896c75 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer.cs @@ -11,6 +11,7 @@ using Yafc.UI; namespace Yafc.Parser; + internal partial class FactorioDataDeserializer { private static readonly ILogger logger = Logging.GetLogger(); private LuaTable raw = null!; // null-forgiving: Initialized at the beginning of LoadData. @@ -21,16 +22,19 @@ internal partial class FactorioDataDeserializer { } result = GetObject(name); + return true; } private T? GetRef(LuaTable table, string key) where T : FactorioObject, new() { _ = GetRef(table, key, out var result); + return result; } private Fluid GetFluidFixedTemp(string key, int temperature) { var basic = GetObject(key); + if (basic.temperature == temperature) { return basic; } @@ -40,9 +44,11 @@ private Fluid GetFluidFixedTemp(string key, int temperature) { } string idWithTemp = key + "@" + temperature; + if (basic.temperature == 0) { basic.SetTemperature(temperature); registeredObjects[(typeof(Fluid), idWithTemp)] = basic; + return basic; } @@ -53,6 +59,7 @@ private Fluid GetFluidFixedTemp(string key, int temperature) { var split = SplitFluid(basic, temperature); allObjects.Add(split); registeredObjects[(typeof(Fluid), idWithTemp)] = split; + return split; } @@ -70,6 +77,7 @@ private void UpdateSplitFluids() { fluid.variants.Sort(DataUtils.FluidTemperatureComparer); fluidVariants[fluid.type + "." + fluid.name] = fluid.variants; + foreach (var variant in fluid.variants) { AddTemperatureToFluidIcon(variant); variant.name += "@" + variant.temperature; @@ -92,18 +100,23 @@ private static void AddTemperatureToFluidIcon(Fluid fluid) { /// The path to the project file to create or load. May be or empty. /// The Lua table data (containing data.raw) that was populated by the lua scripts. /// The Lua table defines.prototypes that was populated by the lua scripts. - /// If , recipe selection windows will only display recipes that provide net production or consumption of the in question. + /// If , recipe selection windows will only display recipes that provide net production or consumption + /// of the in question. /// If , recipe selection windows will show all recipes that produce or consume any quantity of that .
/// For example, Kovarex enrichment will appear for both production and consumption of both U-235 and U-238 when , /// but will appear as only producing U-235 and consuming U-238 when . /// An that receives two strings describing the current loading state. /// An that will collect the errors and warnings encountered while loading and processing the file and data. /// If , Yafc will render the icons necessary for UI display. - /// A containing the information loaded from . Also sets the properties in . - public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, bool netProduction, IProgress<(string, string)> progress, ErrorCollector errorCollector, bool renderIcons) { + /// A containing the information loaded from . Also sets the properties + /// in . + public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, bool netProduction, + IProgress<(string, string)> progress, ErrorCollector errorCollector, bool renderIcons) { + progress.Report(("Loading", "Loading items")); raw = (LuaTable?)data["raw"] ?? throw new ArgumentException("Could not load data.raw from data argument", nameof(data)); LuaTable itemPrototypes = (LuaTable?)prototypes?["item"] ?? throw new ArgumentException("Could not load prototypes.item from data argument", nameof(prototypes)); + foreach (object prototypeName in itemPrototypes.ObjectElements.Keys) { DeserializePrototypes(raw, (string)prototypeName, DeserializeItem, progress, errorCollector); } @@ -111,9 +124,8 @@ public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, Module[] universalModulesArray = [.. universalModules]; IEnumerable FilteredModules(Recipe item) { // When the blacklist is available, filter out modules that are in this blacklist - Func AllowedModulesFilter(Recipe key) { - return module => module.moduleSpecification.limitation_blacklist == null || !module.moduleSpecification.limitation_blacklist.Contains(key); - } + Func AllowedModulesFilter(Recipe key) => module + => module.moduleSpecification.limitation_blacklist == null || !module.moduleSpecification.limitation_blacklist.Contains(key); return universalModulesArray.Where(AllowedModulesFilter(item)); } @@ -129,6 +141,7 @@ Func AllowedModulesFilter(Recipe key) { progress.Report(("Loading", "Loading entities")); DeserializeRocketEntities(raw["rocket-silo-rocket"] as LuaTable); LuaTable entityPrototypes = (LuaTable?)prototypes["entity"] ?? throw new ArgumentException("Could not load prototypes.item from data argument", nameof(prototypes)); + foreach (object prototypeName in entityPrototypes.ObjectElements.Keys) { DeserializePrototypes(raw, (string)prototypeName, DeserializeEntity, progress, errorCollector); } @@ -138,6 +151,7 @@ Func AllowedModulesFilter(Recipe key) { // Deterministically sort all objects allObjects.Sort((a, b) => a.sortingOrder == b.sortingOrder ? string.Compare(a.typeDotName, b.typeDotName, StringComparison.Ordinal) : a.sortingOrder - b.sortingOrder); + for (int i = 0; i < allObjects.Count; i++) { allObjects[i].id = (FactorioId)i; } @@ -157,12 +171,14 @@ Func AllowedModulesFilter(Recipe key) { progress.Report(("Rendering icons", "")); iconRenderedProgress = progress; iconRenderTask.Wait(); + return project; } private IProgress<(string, string)>? iconRenderedProgress; - private Icon CreateSimpleIcon(Dictionary<(string mod, string path), IntPtr> cache, string graphicsPath) => CreateIconFromSpec(cache, new FactorioIconPart("__core__/graphics/" + graphicsPath + ".png")); + private Icon CreateSimpleIcon(Dictionary<(string mod, string path), IntPtr> cache, string graphicsPath) + => CreateIconFromSpec(cache, new FactorioIconPart("__core__/graphics/" + graphicsPath + ".png")); private void RenderIcons() { Dictionary<(string mod, string path), IntPtr> cache = []; @@ -185,6 +201,7 @@ private void RenderIcons() { if (o.iconSpec != null && o.iconSpec.Length > 0) { bool simpleSprite = o.iconSpec.Length == 1 && o.iconSpec[0].IsSimple(); + if (simpleSprite && simpleSpritesCache.TryGetValue(o.iconSpec[0].path, out var icon)) { o.icon = icon; continue; @@ -192,6 +209,7 @@ private void RenderIcons() { try { o.icon = CreateIconFromSpec(cache, o.iconSpec); + if (simpleSprite) { simpleSpritesCache[o.iconSpec[0].path] = o.icon; } @@ -218,10 +236,13 @@ private unsafe Icon CreateIconFromSpec(Dictionary<(string mod, string path), Int const int IconSize = IconCollection.IconSize; nint targetSurface = SDL.SDL_CreateRGBSurfaceWithFormat(0, IconSize, IconSize, 0, SDL.SDL_PIXELFORMAT_RGBA8888); _ = SDL.SDL_SetSurfaceBlendMode(targetSurface, SDL.SDL_BlendMode.SDL_BLENDMODE_BLEND); + foreach (var icon in spec) { var modpath = FactorioDataSource.ResolveModPath("", icon.path); + if (!cache.TryGetValue(modpath, out nint image)) { byte[] imageSource = FactorioDataSource.ReadModFile(modpath.mod, modpath.path); + if (imageSource == null) { image = cache[modpath] = IntPtr.Zero; } @@ -229,9 +250,11 @@ private unsafe Icon CreateIconFromSpec(Dictionary<(string mod, string path), Int fixed (byte* data = imageSource) { nint src = SDL.SDL_RWFromMem((IntPtr)data, imageSource.Length); image = SDL_image.IMG_Load_RW(src, (int)SDL.SDL_bool.SDL_TRUE); + if (image != IntPtr.Zero) { ref var surface = ref RenderingUtils.AsSdlSurface(image); uint format = Unsafe.AsRef((void*)surface.format).format; + if (format != SDL.SDL_PIXELFORMAT_RGB24 && format != SDL.SDL_PIXELFORMAT_RGBA8888) { // SDL is failing to blit palette surfaces, converting them nint old = image; @@ -247,6 +270,7 @@ private unsafe Icon CreateIconFromSpec(Dictionary<(string mod, string path), Int } } } + if (image == IntPtr.Zero) { continue; } @@ -262,6 +286,7 @@ private unsafe Icon CreateIconFromSpec(Dictionary<(string mod, string path), Int w = targetSize, h = targetSize }; + if (icon.x != 0) { targetRect.x = MathUtils.Clamp(targetRect.x + MathUtils.Round(icon.x * IconSize / icon.size), 0, IconSize - targetRect.w); } @@ -276,12 +301,16 @@ private unsafe Icon CreateIconFromSpec(Dictionary<(string mod, string path), Int }; _ = SDL.SDL_BlitScaled(image, ref srcRect, targetSurface, ref targetRect); } + return IconCollection.AddIcon(targetSurface); } - private static void DeserializePrototypes(LuaTable data, string type, Action deserializer, IProgress<(string, string)> progress, ErrorCollector errorCollector) { + private static void DeserializePrototypes(LuaTable data, string type, Action deserializer, + IProgress<(string, string)> progress, ErrorCollector errorCollector) { + object? table = data[type]; progress.Report(("Building objects", type)); + if (table is not LuaTable luaTable) { return; } @@ -302,6 +331,7 @@ private static float ParseEnergy(string? energy) { // internally store energy in megawatts / megajoules to be closer to 1 if (char.IsLetter(energyMul)) { float energyBase = float.Parse(energy[..^2]); + switch (energyMul) { case 'k': case 'K': return energyBase * 1e-3f; @@ -327,10 +357,13 @@ private void DeserializeItem(LuaTable table, ErrorCollector _) { productivity = moduleEffect.Get("productivity", out t) ? t.Get("bonus", 0f) : 0f, pollution = moduleEffect.Get("pollution", out t) ? t.Get("bonus", 0f) : 0f, }; + if (table.Get("limitation", out LuaTable? limitation)) { var limitationArr = limitation.ArrayElements().Select(GetObject).ToArray(); + if (limitationArr.Length > 0) { module.moduleSpecification.limitation = limitationArr; + foreach (var recipe in module.moduleSpecification.limitation) { recipeModules.Add(recipe, module, true); } @@ -340,6 +373,7 @@ private void DeserializeItem(LuaTable table, ErrorCollector _) { // Load blacklisted modules for these recipes, this will be applied later against the universal modules if (table.Get("limitation_blacklist", out LuaTable? limitation_blacklist)) { Recipe[] limitationArr = limitation_blacklist.ArrayElements().Select(GetObject).ToArray(); + if (limitationArr.Length > 0) { module.moduleSpecification.limitation_blacklist = limitationArr; } @@ -357,8 +391,10 @@ private void DeserializeItem(LuaTable table, ErrorCollector _) { } item.stackSize = table.Get("stack_size", 1); + if (item.locName == null && table.Get("placed_as_equipment_result", out string? result)) { Localize("equipment-name." + result, null); + if (localeBuilder.Length > 0) { item.locName = FinishLocalize(); } @@ -366,6 +402,7 @@ private void DeserializeItem(LuaTable table, ErrorCollector _) { if (table.Get("fuel_value", out string? fuelValue)) { item.fuelValue = ParseEnergy(fuelValue); item.fuelResult = GetRef(table, "burnt_result"); + if (table.Get("fuel_category", out string? category)) { fuels.Add(category, item); } @@ -373,7 +410,7 @@ private void DeserializeItem(LuaTable table, ErrorCollector _) { Product[]? launchProducts = null; if (table.Get("rocket_launch_product", out LuaTable? product)) { - launchProducts = LoadProduct("rocket_launch_product", item.stackSize)(product).SingleElementArray(); + launchProducts = [LoadProduct("rocket_launch_product", item.stackSize)(product)]; } else if (table.Get("rocket_launch_products", out LuaTable? products)) { launchProducts = products.ArrayElements().Select(LoadProduct("rocket_launch_products", item.stackSize)).ToArray(); @@ -397,22 +434,27 @@ private Fluid SplitFluid(Fluid basic, int temperature) { var copy = basic.Clone(); copy.SetTemperature(temperature); copy.variants!.Add(copy); // null-forgiving: Clone was given a non-null variants. + if (copy.fuelValue > 0f) { fuels.Add(SpecialNames.BurnableFluid, copy); } fuels.Add(SpecialNames.SpecificFluid + basic.name, copy); + return copy; } private void DeserializeFluid(LuaTable table, ErrorCollector _) { var fluid = DeserializeCommon(table, "fluid"); fluid.originalName = fluid.name; + if (table.Get("fuel_value", out string? fuelValue)) { fluid.fuelValue = ParseEnergy(fuelValue); fuels.Add(SpecialNames.BurnableFluid, fluid); } + fuels.Add(SpecialNames.SpecificFluid + fluid.name, fluid); + if (table.Get("heat_capacity", out string? heatCap)) { fluid.heatCapacity = ParseEnergy(heatCap); } @@ -440,17 +482,22 @@ private bool LoadItemData(LuaTable table, bool useTemperature, string typeDotNam if (table.Get("name", out _)) { goods = LoadItemOrFluid(table, useTemperature, out string? name); _ = table.Get("amount", out amount); + if (goods is null) { throw new NotSupportedException($"Could not load one of the products for {typeDotName}, possibly named '{name}'."); } + return true; // true means 'may have extra data' } else { _ = table.Get(2, out amount); + if (!table.Get(1, out string? name)) { throw new NotSupportedException($"Could not load one of the products for {typeDotName}, due to a missing name."); } + goods = GetObject(name); + return false; } } @@ -479,6 +526,7 @@ private string FinishLocalize() { int state = 0, tagStart = 0; for (int i = 0; i < localeBuilder.Length; i++) { char chr = localeBuilder[i]; + switch (state) { case 0: if (chr == '[') { @@ -516,6 +564,7 @@ private string FinishLocalize() { string s = localeBuilder.ToString(); _ = localeBuilder.Clear(); + return s; } @@ -537,6 +586,7 @@ private void Localize(string? key, LuaTable? table) { } key = FactorioLocalization.Localize(key); + if (key == null) { if (table != null) { _ = localeBuilder.Append(string.Join(" ", table.ArrayElements())); @@ -551,13 +601,16 @@ private void Localize(string? key, LuaTable? table) { } using var parts = ((IEnumerable)key.Split("__")).GetEnumerator(); + while (parts.MoveNext()) { _ = localeBuilder.Append(parts.Current); + if (!parts.MoveNext()) { break; } string control = parts.Current; + if (control is "ITEM" or "FLUID" or "RECIPE" or "ENTITY") { if (!parts.MoveNext()) { break; @@ -593,6 +646,7 @@ private void Localize(string? key, LuaTable? table) { } else if (control.StartsWith("plural")) { _ = localeBuilder.Append("(???)"); + if (!parts.MoveNext()) { break; } @@ -612,6 +666,7 @@ private void Localize(string? key, LuaTable? table) { if (!table.Get("name", out string? name)) { throw new NotSupportedException($"Read a definition of a {prototypeType} that does not have a name."); } + var target = GetObject(name); target.factorioType = table.Get("type", ""); @@ -632,19 +687,21 @@ private void Localize(string? key, LuaTable? table) { } target.locDescr = localeBuilder.Length == 0 ? null : FinishLocalize(); - _ = table.Get("icon_size", out float defaultIconSize); + if (table.Get("icon", out string? s)) { - target.iconSpec = new FactorioIconPart(s) { size = defaultIconSize }.SingleElementArray(); + target.iconSpec = [new FactorioIconPart(s) { size = defaultIconSize }]; } else if (table.Get("icons", out LuaTable? iconList)) { target.iconSpec = iconList.ArrayElements().Select(x => { if (!x.Get("icon", out string? path)) { throw new NotSupportedException($"One of the icon layers for {name} does not have a path."); } + FactorioIconPart part = new FactorioIconPart(path); _ = x.Get("icon_size", out part.size, defaultIconSize); _ = x.Get("scale", out part.scale, 1f); + if (x.Get("shift", out LuaTable? shift)) { _ = shift.Get(1, out part.x); _ = shift.Get(2, out part.y); @@ -656,6 +713,7 @@ private void Localize(string? key, LuaTable? table) { _ = tint.Get("b", out part.b, 1f); _ = tint.Get("a", out part.a, 1f); } + return part; }).ToArray(); } diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs b/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs index ad6bace8..21fb1242 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs @@ -4,6 +4,7 @@ using Yafc.Model; namespace Yafc.Parser; + internal partial class FactorioDataDeserializer { private readonly List allObjects = []; private readonly List rootAccessible = []; @@ -47,7 +48,7 @@ Special createSpecialObject(bool isPower, string name, string locName, string lo obj.factorioType = "special"; obj.locName = locName; obj.locDescr = locDescr; - obj.iconSpec = new FactorioIconPart(icon).SingleElementArray(); + obj.iconSpec = [new FactorioIconPart(icon)]; obj.power = isPower; if (isPower) { obj.fuelValue = 1f; @@ -67,18 +68,20 @@ Special createSpecialObject(bool isPower, string name, string locName, string lo fuels.Add(SpecialNames.Void, voidEnergy); rootAccessible.Add(voidEnergy); - rocketLaunch = createSpecialObject(false, SpecialNames.RocketLaunch, "Rocket launch slot", "This is a slot in a rocket ready to be launched", "__base__/graphics/entity/rocket-silo/02-rocket.png", "signal-R"); - researchUnit = createSpecialObject(false, SpecialNames.ResearchUnit, "Research", "This represents one unit of a research task.", "__base__/graphics/icons/compilatron.png", "signal-L"); + rocketLaunch = createSpecialObject(false, SpecialNames.RocketLaunch, "Rocket launch slot", + "This is a slot in a rocket ready to be launched", "__base__/graphics/entity/rocket-silo/02-rocket.png", "signal-R"); + researchUnit = createSpecialObject(false, SpecialNames.ResearchUnit, "Research", + "This represents one unit of a research task.", "__base__/graphics/icons/compilatron.png", "signal-L"); researchUnit.isResearch = true; Analysis.ExcludeFromAnalysis(researchUnit); generatorProduction = CreateSpecialRecipe(electricity, SpecialNames.GeneratorRecipe, "generating"); - generatorProduction.products = new Product(electricity, 1f).SingleElementArray(); + generatorProduction.products = [new Product(electricity, 1f)]; generatorProduction.flags |= RecipeFlags.ScaleProductionWithPower; generatorProduction.ingredients = []; reactorProduction = CreateSpecialRecipe(heat, SpecialNames.ReactorRecipe, "generating"); - reactorProduction.products = new Product(heat, 1f).SingleElementArray(); + reactorProduction.products = [new Product(heat, 1f)]; reactorProduction.flags |= RecipeFlags.ScaleProductionWithPower; reactorProduction.ingredients = []; @@ -86,9 +89,7 @@ Special createSpecialObject(bool isPower, string name, string locName, string lo laborEntityEnergy = new EntityEnergy { type = EntityEnergyType.Labor, effectivity = float.PositiveInfinity }; } - private T GetObject(string name) where T : FactorioObject, new() { - return GetObject(name); - } + private T GetObject(string name) where T : FactorioObject, new() => GetObject(name); private TActual GetObject(string name) where TNominal : FactorioObject where TActual : TNominal, new() { var key = (typeof(TNominal), name); @@ -113,13 +114,13 @@ private int Skip(int from, FactorioObjectSortOrder sortOrder) { } private void ExportBuiltData() { - Database.rootAccessible = rootAccessible.ToArray(); + Database.rootAccessible = [.. rootAccessible]; Database.objectsByTypeName = allObjects.ToDictionary(x => x.typeDotName); foreach (var alias in formerAliases) { _ = Database.objectsByTypeName.TryAdd(alias.Key, alias.Value); } - Database.allSciencePacks = sciencePacks.ToArray(); + Database.allSciencePacks = [.. sciencePacks]; Database.voidEnergy = voidEnergy; Database.researchUnit = researchUnit; Database.electricity = electricity; @@ -189,7 +190,6 @@ private static bool AreInverseRecipes(Recipe packing, Recipe unpacking) { return true; - // Test to see if running `first` M times and `second` once, or vice versa, can reproduce all the original input. // Track which recipe is larger to keep ratio an integer and prevent floating point rounding issues. static bool checkRatios(Recipe first, Recipe second, ref float ratio, ref Recipe? larger) { @@ -199,6 +199,7 @@ static bool checkRatios(Recipe first, Recipe second, ref float ratio, ref Recipe if (ingredients.ContainsKey(item.goods)) { return false; // Refuse to deal with duplicate ingredients. } + ingredients[item.goods] = item.amount; } @@ -206,6 +207,7 @@ static bool checkRatios(Recipe first, Recipe second, ref float ratio, ref Recipe if (!ingredients.TryGetValue(item.goods, out float count)) { return false; } + if (count > item.amount) { if (!checkProportions(first, count, item.amount, ref ratio, ref larger)) { return false; @@ -223,6 +225,7 @@ static bool checkRatios(Recipe first, Recipe second, ref float ratio, ref Recipe } } } + return true; } @@ -240,6 +243,7 @@ static bool checkProportions(Recipe currentLargerRecipe, float largerCount, floa } ratio = largerCount / smallerCount; larger = currentLargerRecipe; + return true; } } @@ -275,12 +279,14 @@ private void CalculateMaps(bool netProduction) { break; case Recipe recipe: allRecipes.Add(recipe); + foreach (var product in recipe.products) { // If the ingredient has variants and is an output, we aren't doing catalyst things: water@15-90 to water@90 does produce water@90, // even if it consumes 10 water@15-90 to produce 9 water@90. Ingredient? ingredient = recipe.ingredients.FirstOrDefault(i => i.goods == product.goods && i.variants is null); float inputAmount = netProduction ? (ingredient?.amount ?? 0) : 0; float outputAmount = product.amount; + if (outputAmount > inputAmount) { itemProduction.Add(product.goods, recipe); } @@ -297,6 +303,7 @@ private void CalculateMaps(bool netProduction) { } else if (ingredient.variants != null) { ingredient.goods = ingredient.variants[0]; + foreach (var variant in ingredient.variants) { itemUsages.Add(variant, recipe); } @@ -310,6 +317,7 @@ private void CalculateMaps(bool netProduction) { case Item item: if (placeResults.TryGetValue(item, out var placeResultNames)) { item.placeResult = GetObject(placeResultNames[0]); + foreach (string name in placeResultNames) { entityPlacers.Add(GetObject(name), item); } @@ -325,23 +333,28 @@ private void CalculateMaps(bool netProduction) { } if (entity is EntityCrafter crafter) { - crafter.recipes = recipeCrafters.GetRaw(crafter).SelectMany(x => recipeCategories.GetRaw(x).Where(y => y.CanFit(crafter.itemInputs, crafter.fluidInputs, crafter.inputs))).ToArray(); + crafter.recipes = recipeCrafters.GetRaw(crafter) + .SelectMany(x => recipeCategories.GetRaw(x).Where(y => y.CanFit(crafter.itemInputs, crafter.fluidInputs, crafter.inputs))).ToArray(); foreach (var recipe in crafter.recipes) { actualRecipeCrafters.Add(recipe, crafter, true); } } + if (entity.energy != null && entity.energy != voidEntityEnergy) { var fuelList = fuelUsers.GetRaw(entity).SelectMany(fuels.GetRaw); + if (entity.energy.type == EntityEnergyType.FluidHeat) { fuelList = fuelList.Where(x => x is Fluid f && entity.energy.acceptedTemperature.Contains(f.temperature) && f.temperature > entity.energy.workingTemperature.min); } var fuelListArr = fuelList.ToArray(); entity.energy.fuels = fuelListArr; + foreach (var fuel in fuelListArr) { usageAsFuel.Add(fuel, entity); } } + break; } } @@ -362,12 +375,14 @@ private void CalculateMaps(bool netProduction) { recipe.FallbackLocalization(recipe.mainProduct, "A recipe to create"); recipe.technologyUnlock = recipeUnlockers.GetArray(recipe); } + recipeOrTechnology.crafters = actualRecipeCrafters.GetArray(recipeOrTechnology); break; case Goods goods: goods.usages = itemUsages.GetArray(goods); goods.production = itemProduction.GetArray(goods); goods.miscSources = miscSources.GetArray(goods); + if (o is Item item) { if (item.placeResult != null) { item.FallbackLocalization(item.placeResult, "An item to build"); @@ -400,6 +415,7 @@ private void CalculateMaps(bool netProduction) { && crafter.recipes.SingleOrDefault(r => r.GetType() == typeof(Recipe), false) is Recipe { enabled: false } fixedRecipe) { bool addedUnlocks = false; + foreach (Recipe itemRecipe in crafter.itemsToPlace.SelectMany(i => i.production)) { // and (a recipe that creates an item that places) the crafter is accessible // from the beginning of the game, the fixed recipe is also accessible. @@ -446,11 +462,13 @@ private void CalculateMaps(bool netProduction) { recipe.specialType = FactorioObjectSpecialType.Voiding; continue; } + if (recipe.products.Length != 1 || recipe.ingredients.Length == 0) { continue; } Goods packed = recipe.products[0].goods; + if (countNonDsrRecipes(packed.usages) != 1 && countNonDsrRecipes(packed.production) != 1) { continue; } @@ -484,7 +502,9 @@ private void CalculateMaps(bool netProduction) { unpacking.specialType = FactorioObjectSpecialType.Barreling; packed.specialType = FactorioObjectSpecialType.Barreling; } - else { continue; } + else { + continue; + } // The packed good is used in other recipes or is fuel, constructs a building, or is a module. Only the unpacking recipe should be flagged as special. if (countNonDsrRecipes(packed.usages) != 1 || (packed is Item item && (item.fuelValue != 0 || item.placeResult != null || item is Module))) { @@ -511,13 +531,12 @@ private void CalculateMaps(bool netProduction) { } } // The recipes added by deadlock_stacked_recipes (with CompressedFluids, if present) need to be filtered out to get decent results. - static int countNonDsrRecipes(IEnumerable recipes) { - return recipes.Count(r => !r.name.Contains("StackedRecipe-") && !r.name.Contains("DSR_HighPressure-")); - } + static int countNonDsrRecipes(IEnumerable recipes) => recipes.Count(r => !r.name.Contains("StackedRecipe-") && !r.name.Contains("DSR_HighPressure-")); } private Recipe CreateSpecialRecipe(FactorioObject production, string category, string hint) { string fullName = category + (category.EndsWith('.') ? "" : ".") + production.name; + if (registeredObjects.TryGetValue((typeof(Mechanics), fullName), out var recipeRaw)) { return (Recipe)recipeRaw; } @@ -532,6 +551,7 @@ private Recipe CreateSpecialRecipe(FactorioObject production, string category, s recipe.hidden = true; recipe.technologyUnlock = []; recipeCategories.Add(category, recipe); + return recipe; } @@ -557,7 +577,8 @@ public void Seal(Func>? addExtraItems = null) { defaultList = addExtraItems; } - KeyValuePair>[] values = storage.ToArray(); + KeyValuePair>[] values = [.. storage]; + foreach ((TKey key, IList value) in values) { if (value is not List list) { // Unexpected type, (probably) never happens @@ -596,14 +617,15 @@ public TValue[] GetArray(TKey key) { return defaultList(key).ToArray(); } - return list is TValue[] value ? value : list.ToArray(); + return list is TValue[] value ? value : [.. list]; } public IList GetRaw(TKey key) { if (!storage.TryGetValue(key, out var list)) { list = defaultList(key).ToList(); + if (isSealed) { - list = list.ToArray(); + list = [.. list]; } storage[key] = list; @@ -612,9 +634,7 @@ public IList GetRaw(TKey key) { } ///Just return an empty enumerable. - private static IEnumerable NoExtraItems(TKey item) { - return []; - } + private static IEnumerable NoExtraItems(TKey item) => []; public bool Equals(List? x, List? y) { if (x is null && y is null) { @@ -641,16 +661,14 @@ public int GetHashCode(List obj) { } } - public Type? TypeNameToType(string? typeName) { - return typeName switch { - "item" => typeof(Item), - "fluid" => typeof(Fluid), - "technology" => typeof(Technology), - "recipe" => typeof(Recipe), - "entity" => typeof(Entity), - _ => null, - }; - } + public static Type? TypeNameToType(string? typeName) => typeName switch { + "item" => typeof(Item), + "fluid" => typeof(Fluid), + "technology" => typeof(Technology), + "recipe" => typeof(Recipe), + "entity" => typeof(Entity), + _ => null, + }; private void ParseModYafcHandles(LuaTable? scriptEnabled) { if (scriptEnabled != null) { diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs index 8fd73c98..e6f7242f 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs @@ -5,11 +5,13 @@ using Yafc.UI; namespace Yafc.Parser; + internal partial class FactorioDataDeserializer { private const float EstimationDistanceFromCenter = 3000f; private bool GetFluidBoxFilter(LuaTable table, string fluidBoxName, int temperature, [NotNullWhen(true)] out Fluid? fluid, out TemperatureRange range) { fluid = null; range = default; + if (!table.Get(fluidBoxName, out LuaTable? fluidBoxData)) { return false; } @@ -21,11 +23,13 @@ private bool GetFluidBoxFilter(LuaTable table, string fluidBoxName, int temperat fluid = temperature == 0 ? GetObject(fluidName) : GetFluidFixedTemp(fluidName, temperature); _ = fluidBoxData.Get("minimum_temperature", out range.min, fluid.temperatureRange.min); _ = fluidBoxData.Get("maximum_temperature", out range.max, fluid.temperatureRange.max); + return true; } - private int CountFluidBoxes(LuaTable list, bool input) { + private static int CountFluidBoxes(LuaTable list, bool input) { int count = 0; + foreach (var fluidBox in list.ArrayElements()) { if (fluidBox.Get("production_type", out string? prodType) && (prodType == "input-output" || (input && prodType == "input") || (!input && prodType == "output"))) { ++count; @@ -39,8 +43,8 @@ private void ReadFluidEnergySource(LuaTable energySource, Entity entity) { var energy = entity.energy; _ = energySource.Get("burns_fluid", out bool burns, false); energy.type = burns ? EntityEnergyType.FluidFuel : EntityEnergyType.FluidHeat; - energy.workingTemperature = TemperatureRange.Any; + if (energySource.Get("fluid_usage_per_tick", out float fuelLimit)) { energy.fuelConsumptionLimit = fuelLimit * 60f; } @@ -66,14 +70,17 @@ private void ReadFluidEnergySource(LuaTable energySource, Entity entity) { private void ReadEnergySource(LuaTable energySource, Entity entity, float defaultDrain = 0f) { _ = energySource.Get("type", out string type, "burner"); + if (type == "void") { entity.energy = voidEntityEnergy; return; } + EntityEnergy energy = new EntityEnergy(); entity.energy = energy; energy.emissions = energySource.Get("emissions_per_minute", 0f); energy.effectivity = energySource.Get("effectivity", 1f); + switch (type) { case "electric": fuelUsers.Add(entity, SpecialNames.Electricity); @@ -104,17 +111,18 @@ private void ReadEnergySource(LuaTable energySource, Entity entity, float defaul } } - private int GetSize(LuaTable box) { + private static int GetSize(LuaTable box) { _ = box.Get(1, out LuaTable? topLeft); _ = box.Get(2, out LuaTable? bottomRight); _ = topLeft.Get(1, out float x0); _ = topLeft.Get(2, out float y0); _ = bottomRight.Get(1, out float x1); _ = bottomRight.Get(2, out float y1); + return Math.Max(MathUtils.Round(x1 - x0), MathUtils.Round(y1 - y0)); } - private void ParseModules(LuaTable table, EntityWithModules entity, AllowedEffects def) { + private static void ParseModules(LuaTable table, EntityWithModules entity, AllowedEffects def) { if (table.Get("allowed_effects", out object? obj)) { if (obj is string s) { entity.allowedEffects = (AllowedEffects)Enum.Parse(typeof(AllowedEffects), s, true); @@ -140,9 +148,10 @@ private Recipe CreateLaunchRecipe(EntityCrafter entity, Recipe recipe, int parts var launchRecipe = CreateSpecialRecipe(recipe, launchCategory, "launch"); recipeCrafters.Add(entity, launchCategory); launchRecipe.ingredients = recipe.products.Select(x => new Ingredient(x.goods, x.amount * partsRequired)).ToArray(); - launchRecipe.products = new Product(rocketLaunch, outputCount).SingleElementArray(); + launchRecipe.products = [new Product(rocketLaunch, outputCount)]; launchRecipe.time = 40.33f / outputCount; recipeCrafters.Add(entity, SpecialNames.RocketLaunch); + return launchRecipe; } @@ -174,15 +183,18 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { switch (factorioType) { case "transport-belt": - GetObject(name).beltItemsPerSecond = table.Get("speed", 0f) * 480f; ; + GetObject(name).beltItemsPerSecond = table.Get("speed", 0f) * 480f; + break; case "inserter": var inserter = GetObject(name); inserter.inserterSwingTime = 1f / (table.Get("rotation_speed", 1f) * 60); inserter.isStackInserter = table.Get("stack", false); + break; case "accumulator": var accumulator = GetObject(name); + if (table.Get("energy_source", out LuaTable? accumulatorEnergy) && accumulatorEnergy.Get("buffer_capacity", out string? capacity)) { accumulator.accumulatorCapacity = ParseEnergy(capacity); } @@ -195,6 +207,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { reactor.power = ParseEnergy(usesPower); reactor.craftingSpeed = reactor.power; recipeCrafters.Add(reactor, SpecialNames.ReactorRecipe); + break; case "beacon": var beacon = GetObject(name); @@ -202,11 +215,13 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { _ = table.Get("energy_usage", out usesPower); ParseModules(table, beacon, AllowedEffects.All ^ AllowedEffects.Productivity); beacon.power = ParseEnergy(usesPower); + break; case "logistic-container": case "container": var container = GetObject(name); container.inventorySize = table.Get("inventory_size", 0); + if (factorioType == "logistic-container") { container.logisticMode = table.Get("logistic_mode", ""); container.logisticSlotsCount = table.Get("logistic_slots_count", 0); @@ -214,10 +229,12 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { container.logisticSlotsCount = table.Get("max_logistic_slots", 1000); } } + break; case "character": var character = GetObject(name); character.itemInputs = 255; + if (table.Get("mining_categories", out LuaTable? resourceCategories)) { foreach (string playerMining in resourceCategories.ArrayElements()) { recipeCrafters.Add(character, SpecialNames.MiningRecipe + playerMining); @@ -236,6 +253,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { character.mapGenerated = true; rootAccessible.Insert(0, character); } + break; case "boiler": var boiler = GetObject(name); @@ -246,19 +264,22 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { _ = GetFluidBoxFilter(table, "fluid_box", 0, out Fluid? input, out var acceptTemperature); _ = table.Get("target_temperature", out int targetTemp); Fluid? output = hasOutput ? GetFluidBoxFilter(table, "output_fluid_box", targetTemp, out var fluid, out _) ? fluid : null : input; + if (input == null || output == null) { // TODO - boiler works with any fluid - not supported break; } + // otherwise convert boiler production to a recipe string category = SpecialNames.BoilerRecipe + boiler.name; var recipe = CreateSpecialRecipe(output, category, "boiling to " + targetTemp + "°"); recipeCrafters.Add(boiler, category); recipe.flags |= RecipeFlags.UsesFluidTemperature; - recipe.ingredients = new Ingredient(input, 60) { temperature = acceptTemperature }.SingleElementArray(); - recipe.products = new Product(output, 60).SingleElementArray(); + recipe.ingredients = [new Ingredient(input, 60) { temperature = acceptTemperature }]; + recipe.products = [new Product(output, 60)]; // This doesn't mean anything as RecipeFlags.UsesFluidTemperature overrides recipe time, but looks nice in the tooltip recipe.time = input.heatCapacity * 60 * (output.temperature - Math.Max(input.temperature, input.temperatureRange.min)) / boiler.power; boiler.craftingSpeed = 1f / boiler.power; + break; case "assembling-machine": case "rocket-silo": @@ -270,11 +291,13 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { defaultDrain = crafter.power / 30f; crafter.craftingSpeed = table.Get("crafting_speed", 1f); crafter.itemInputs = factorioType == "furnace" ? table.Get("source_inventory_size", 1) : table.Get("ingredient_count", 255); + if (table.Get("fluid_boxes", out LuaTable? fluidBoxes)) { crafter.fluidInputs = CountFluidBoxes(fluidBoxes, true); } Recipe? fixedRecipe = null; + if (table.Get("fixed_recipe", out string? fixedRecipeName)) { string fixedRecipeCategoryName = SpecialNames.FixedRecipe + fixedRecipeName; fixedRecipe = GetObject(fixedRecipeName); @@ -290,9 +313,11 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { if (factorioType == "rocket-silo") { int resultInventorySize = table.Get("rocket_result_inventory_size", 1); + if (resultInventorySize > 0) { int outputCount = table.Get("rocket_entity", out string? rocketEntity) && rocketInventorySizes.TryGetValue(rocketEntity, out int value) ? value : 1; _ = table.Get("rocket_parts_required", out int partsRequired, 100); + if (fixedRecipe != null) { var launchRecipe = CreateLaunchRecipe(crafter, fixedRecipe, partsRequired, outputCount); formerAliases["Mechanics.launch" + crafter.name + "." + crafter.name] = launchRecipe; @@ -308,10 +333,12 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { } } } + break; case "generator": case "burner-generator": var generator = GetObject(name); + // generator energy input config is strange if (table.Get("max_power_output", out string? maxPowerOutput)) { generator.power = ParseEnergy(maxPowerOutput); @@ -324,7 +351,9 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { generator.energy = new EntityEnergy { effectivity = table.Get("effectivity", 1f) }; ReadFluidEnergySource(table, generator); } + recipeCrafters.Add(generator, SpecialNames.GeneratorRecipe); + break; case "mining-drill": var drill = GetObject(name); @@ -333,6 +362,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { ParseModules(table, drill, AllowedEffects.All); drill.craftingSpeed = table.Get("mining_speed", 1f); _ = table.Get("resource_categories", out resourceCategories); + if (table.Get("input_fluid_box", out LuaTable? _)) { drill.fluidInputs = 1; } @@ -345,14 +375,16 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { case "offshore-pump": var pump = GetObject(name); pump.craftingSpeed = table.Get("pumping_speed", 20f) / 20f; + if (table.Get("fluid", out string? fluidName)) { var pumpingFluid = GetFluidFixedTemp(fluidName, 0); string recipeCategory = SpecialNames.PumpingRecipe + pumpingFluid.name; recipe = CreateSpecialRecipe(pumpingFluid, recipeCategory, "pumping"); recipeCrafters.Add(pump, recipeCategory); pump.energy = voidEntityEnergy; + if (recipe.products == null) { - recipe.products = new Product(pumpingFluid, 1200f).SingleElementArray(); // set to Factorio default pump amounts - looks nice in tooltip + recipe.products = [new Product(pumpingFluid, 1200f)]; // set to Factorio default pump amounts - looks nice in tooltip recipe.ingredients = []; recipe.time = 1f; } @@ -360,6 +392,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { else { errorCollector.Error($"Could not load offshore pump {name}, because it does not pump anything.", ErrorSeverity.AnalysisWarning); } + break; case "lab": var lab = GetObject(name); @@ -372,6 +405,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { lab.inputs = inputs.ArrayElements().Select(GetObject).ToArray(); sciencePacks.UnionWith(lab.inputs.Select(x => (Item)x)); lab.itemInputs = lab.inputs.Length; + break; case "solar-panel": var solarPanel = GetObject(name); @@ -379,16 +413,19 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { _ = table.Get("production", out string? powerProduction); recipeCrafters.Add(solarPanel, SpecialNames.GeneratorRecipe); solarPanel.craftingSpeed = ParseEnergy(powerProduction) * 0.7f; // 0.7f is a solar panel ratio on nauvis + break; case "electric-energy-interface": var eei = GetObject(name); eei.energy = voidEntityEnergy; + if (table.Get("energy_production", out string? interfaceProduction)) { eei.craftingSpeed = ParseEnergy(interfaceProduction); if (eei.craftingSpeed > 0) { recipeCrafters.Add(eei, SpecialNames.GeneratorRecipe); } } + break; case "constant-combinator": if (name == "constant-combinator") { @@ -409,6 +446,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { if (table.Get("minable", out LuaTable? minable)) { var products = LoadProductList(minable, "minable"); + if (factorioType == "resource") { // mining resource is processed as a recipe _ = table.Get("category", out string category, "basic-solid"); @@ -418,9 +456,10 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { recipe.products = products; recipe.modules = [.. allModules]; recipe.sourceEntity = entity; + if (minable.Get("required_fluid", out string? requiredFluid)) { _ = minable.Get("fluid_amount", out float amount); - recipe.ingredients = new Ingredient(GetObject(requiredFluid), amount / 10f).SingleElementArray(); // 10x difference is correct but why? + recipe.ingredients = [new Ingredient(GetObject(requiredFluid), amount / 10f)]; // 10x difference is correct but why? } else { recipe.ingredients = []; @@ -435,9 +474,12 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { entity.size = table.Get("selection_box", out LuaTable? box) ? GetSize(box) : 3; _ = table.Get("energy_source", out LuaTable? energySource); + // These types have already called ReadEnergySource/ReadFluidEnergySource (generator, burner generator) or don't consume energy from YAFC's point of view (pump to EII). // TODO: Work with AAI-I to support offshore pumps that consume energy. - if (factorioType is not "generator" and not "burner-generator" and not "offshore-pump" and not "solar-panel" and not "accumulator" and not "electric-energy-interface" && energySource != null) { + if (factorioType is not "generator" and not "burner-generator" and not "offshore-pump" and not "solar-panel" and not "accumulator" and not "electric-energy-interface" + && energySource != null) { + ReadEnergySource(energySource, entity, defaultDrain); } @@ -448,6 +490,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { if (table.Get("autoplace", out LuaTable? generation)) { entity.mapGenerated = true; rootAccessible.Add(entity); + if (generation.Get("probability_expression", out LuaTable? prob)) { float probability = EstimateNoiseExpression(prob); float richness = generation.Get("richness_expression", out LuaTable? rich) ? EstimateNoiseExpression(rich) : probability; @@ -469,19 +512,17 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { } } - private float EstimateArgument(LuaTable args, string name, float def = 0) { - return args.Get(name, out LuaTable? res) ? EstimateNoiseExpression(res) : def; - } + private float EstimateArgument(LuaTable args, string name, float def = 0) => args.Get(name, out LuaTable? res) ? EstimateNoiseExpression(res) : def; - private float EstimateArgument(LuaTable args, int index, float def = 0) { - return args.Get(index, out LuaTable? res) ? EstimateNoiseExpression(res) : def; - } + private float EstimateArgument(LuaTable args, int index, float def = 0) => args.Get(index, out LuaTable? res) ? EstimateNoiseExpression(res) : def; private float EstimateNoiseExpression(LuaTable expression) { string type = expression.Get("type", "typed"); + switch (type) { case "variable": string varname = expression.Get("variable_name", ""); + if (varname is "x" or "y" or "distance") { return EstimationDistanceFromCenter; } @@ -494,24 +535,30 @@ private float EstimateNoiseExpression(LuaTable expression) { case "function-application": string funName = expression.Get("function_name", ""); var args = expression.Get("arguments"); + if (args is null) { return 0; } + switch (funName) { case "add": float res = 0f; + foreach (var el in args.ArrayElements()) { res += EstimateNoiseExpression(el); } return res; + case "multiply": res = 1f; + foreach (var el in args.ArrayElements()) { res *= EstimateNoiseExpression(el); } return res; + case "subtract": return EstimateArgument(args, 1) - EstimateArgument(args, 2); case "divide": @@ -533,14 +580,17 @@ private float EstimateNoiseExpression(LuaTable expression) { case "random-penalty": float source = EstimateArgument(args, "source"); float penalty = EstimateArgument(args, "amplitude"); + if (penalty > source) { return source / penalty; } return (source + source - penalty) / 2; + case "spot-noise": float quantity = EstimateArgument(args, "spot_quantity_expression"); float spotCount; + if (args.Get("candidate_spot_count", out LuaTable? spots)) { spotCount = EstimateNoiseExpression(spots); } @@ -551,7 +601,9 @@ private float EstimateNoiseExpression(LuaTable expression) { float regionSize = EstimateArgument(args, "region_size", 512); regionSize *= regionSize; float count = spotCount * quantity / regionSize; + return count; + case "factorio-basis-noise": case "factorio-quick-multioctave-noise": case "factorio-multioctave-noise": diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs b/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs index 7872fb62..592d0f1d 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs @@ -3,11 +3,15 @@ using Yafc.Model; namespace Yafc.Parser; + internal partial class FactorioDataDeserializer { - private T DeserializeWithDifficulty(LuaTable table, string prototypeType, Action loader, ErrorCollector errorCollector) where T : FactorioObject, new() { + private T DeserializeWithDifficulty(LuaTable table, string prototypeType, Action loader, ErrorCollector errorCollector) + where T : FactorioObject, new() { + var obj = DeserializeCommon(table, prototypeType); object? current = expensiveRecipes ? table["expensive"] : table["normal"]; object? fallback = expensiveRecipes ? table["normal"] : table["expensive"]; + if (current is LuaTable c) { loader(obj, c, false, errorCollector); } @@ -31,6 +35,7 @@ private void DeserializeRecipe(LuaTable table, ErrorCollector errorCollector) { private static void DeserializeFlags(LuaTable table, RecipeOrTechnology recipe, bool forceDisable) { recipe.hidden = table.Get("hidden", true); + if (forceDisable) { recipe.enabled = false; } @@ -51,6 +56,7 @@ private void UpdateRecipeCatalysts() { foreach (var product in recipe.products) { if (product.productivityAmount == product.amount) { float catalyst = recipe.GetConsumptionPerRecipe(product.goods); + if (catalyst > 0f) { product.SetCatalyst(catalyst); } @@ -64,8 +70,10 @@ private void UpdateRecipeIngredientFluids() { foreach (var ingredient in recipe.ingredients) { if (ingredient.goods is Fluid fluid && fluid.variants != null) { int min = -1, max = fluid.variants.Count - 1; + for (int i = 0; i < fluid.variants.Count; i++) { var variant = fluid.variants[i]; + if (variant.temperature < ingredient.temperature.min) { continue; } @@ -82,6 +90,7 @@ private void UpdateRecipeIngredientFluids() { if (min >= 0 && max >= 0) { ingredient.goods = fluid.variants[min]; + if (max > min) { Fluid[] fluidVariants = new Fluid[max - min + 1]; ingredient.variants = fluidVariants; @@ -100,9 +109,11 @@ private void LoadTechnologyData(Technology technology, LuaTable table, bool forc else { errorCollector.Error($"Could not get science packs for {technology.name}.", ErrorSeverity.AnalysisWarning); } + DeserializeFlags(table, technology, forceDisable); technology.time = unit.Get("time", 1f); technology.count = unit.Get("count", 1000f); + if (table.Get("prerequisites", out LuaTable? prerequisitesList)) { technology.prerequisites = prerequisitesList.ArrayElements().Select(GetObject).ToArray(); } @@ -114,27 +125,27 @@ private void LoadTechnologyData(Technology technology, LuaTable table, bool forc } } - private Func LoadProduct(string typeDotName, int multiplier = 1) { - return table => { - bool haveExtraData = LoadItemData(table, true, typeDotName, out var goods, out float amount); - amount *= multiplier; - float min = amount, max = amount; - if (haveExtraData && amount == 0) { - _ = table.Get("amount_min", out min); - _ = table.Get("amount_max", out max); - min *= multiplier; - max *= multiplier; - } + private Func LoadProduct(string typeDotName, int multiplier = 1) => table => { + bool haveExtraData = LoadItemData(table, true, typeDotName, out var goods, out float amount); + amount *= multiplier; + float min = amount, max = amount; - Product product = new Product(goods, min, max, table.Get("probability", 1f)); - float catalyst = table.Get("catalyst_amount", 0f); - if (catalyst > 0f) { - product.SetCatalyst(catalyst); - } + if (haveExtraData && amount == 0) { + _ = table.Get("amount_min", out min); + _ = table.Get("amount_max", out max); + min *= multiplier; + max *= multiplier; + } - return product; - }; - } + Product product = new Product(goods, min, max, table.Get("probability", 1f)); + float catalyst = table.Get("catalyst_amount", 0f); + + if (catalyst > 0f) { + product.SetCatalyst(catalyst); + } + + return product; + }; private Product[] LoadProductList(LuaTable table, string typeDotName) { if (table.Get("results", out LuaTable? resultList)) { @@ -142,28 +153,33 @@ private Product[] LoadProductList(LuaTable table, string typeDotName) { } _ = table.Get("result", out string? name); + if (name == null) { return []; } - Product singleProduct = new Product(GetObject(name), table.Get("result_count", out float amount) ? amount : table.Get("count", 1)); - return singleProduct.SingleElementArray(); + return [(new Product(GetObject(name), table.Get("result_count", out float amount) ? amount : table.Get("count", 1)))]; } private Ingredient[] LoadIngredientList(LuaTable table, string typeDotName, ErrorCollector errorCollector) { _ = table.Get("ingredients", out LuaTable? ingredientsList); + return ingredientsList?.ArrayElements().Select(table => { bool haveExtraData = LoadItemData(table, false, typeDotName, out var goods, out float amount); + if (goods is null) { errorCollector.Error($"Failed to load at least one ingredient for {typeDotName}.", ErrorSeverity.AnalysisWarning); return null!; } + Ingredient ingredient = new Ingredient(goods, amount); + if (haveExtraData && goods is Fluid f) { ingredient.temperature = table.Get("temperature", out int temp) ? new TemperatureRange(temp) : new TemperatureRange(table.Get("minimum_temperature", f.temperatureRange.min), table.Get("maximum_temperature", f.temperatureRange.max)); } + return ingredient; }).Where(x => x is not null).ToArray() ?? []; } diff --git a/Yafc.Parser/FactorioDataSource.cs b/Yafc.Parser/FactorioDataSource.cs index a4919959..33b75942 100644 --- a/Yafc.Parser/FactorioDataSource.cs +++ b/Yafc.Parser/FactorioDataSource.cs @@ -10,6 +10,7 @@ using Yafc.UI; namespace Yafc.Parser; + public static partial class FactorioDataSource { /* If you're wondering why this class is partial, * please check the implementation comment of ModInfo. @@ -23,14 +24,13 @@ private static byte[] ReadAllBytes(this Stream stream, int length) { BinaryReader reader = new BinaryReader(stream); byte[] bytes = reader.ReadBytes(length); stream.Dispose(); + return bytes; } private static readonly byte[] bom = [0xEF, 0xBB, 0xBF]; - public static ReadOnlySpan CleanupBom(this ReadOnlySpan span) { - return span.StartsWith(bom) ? span[bom.Length..] : span; - } + public static ReadOnlySpan CleanupBom(this ReadOnlySpan span) => span.StartsWith(bom) ? span[bom.Length..] : span; private static readonly char[] fileSplittersLua = ['.', '/', '\\']; private static readonly char[] fileSplittersNormal = ['/', '\\']; @@ -42,22 +42,26 @@ public static ReadOnlySpan CleanupBom(this ReadOnlySpan span) { public static (string mod, string path) ResolveModPath(string currentMod, string fullPath, bool isLuaRequire = false) { string mod = currentMod; char[] splitters = fileSplittersNormal; + if (isLuaRequire && !fullPath.Contains('/')) { splitters = fileSplittersLua; } string[] path = fullPath.Split(splitters, StringSplitOptions.RemoveEmptyEntries); + if (Array.IndexOf(path, "..") >= 0) { throw new InvalidOperationException("Attempt to traverse to parent directory"); } IEnumerable pathEnumerable = path; + if (path[0].StartsWith("__") && path[0].EndsWith("__")) { mod = path[0][2..^2]; pathEnumerable = pathEnumerable.Skip(1); } string resolved = string.Join("/", pathEnumerable); + if (isLuaRequire) { resolved += ".lua"; } @@ -67,25 +71,31 @@ public static (string mod, string path) ResolveModPath(string currentMod, string public static bool ModPathExists(string modName, string path) { var info = allMods[modName]; + if (info.zipArchive != null) { return info.zipArchive.GetEntry(info.folder + path) != null; } string fileName = Path.Combine(info.folder, path); + return File.Exists(fileName); } public static byte[] ReadModFile(string modName, string path) { var info = allMods[modName]; + if (info.zipArchive != null) { var entry = info.zipArchive.GetEntry(info.folder + path); + if (entry == null) { return []; } byte[] bytearr = new byte[entry.Length]; + using (var stream = entry.Open()) { int read = 0; + while (read < bytearr.Length) { read += stream.Read(bytearr, read, bytearr.Length - read); } @@ -95,6 +105,7 @@ public static byte[] ReadModFile(string modName, string path) { } string fileName = Path.Combine(info.folder, path); + return File.Exists(fileName) ? File.ReadAllBytes(fileName) : []; } @@ -109,6 +120,7 @@ private static void LoadModLocale(string modName, string locale) { private static void FindMods(string directory, IProgress<(string, string)> progress, List mods) { foreach (string entry in Directory.EnumerateDirectories(directory)) { string infoFile = Path.Combine(entry, "info.json"); + if (File.Exists(infoFile)) { progress.Report(("Initializing", entry)); ModInfo info = new(entry, File.ReadAllBytes(infoFile)); @@ -123,6 +135,7 @@ private static void FindMods(string directory, IProgress<(string, string)> progr var infoEntry = zipArchive.Entries.FirstOrDefault(x => x.Name.Equals("info.json", StringComparison.OrdinalIgnoreCase) && x.FullName.IndexOf('/') == x.FullName.Length - "info.json".Length - 1); + if (infoEntry != null) { ModInfo info = new(infoEntry.FullName[..^"info.json".Length], infoEntry.Open().ReadAllBytes((int)infoEntry.Length)) { zipArchive = zipArchive @@ -137,28 +150,35 @@ private static void FindMods(string directory, IProgress<(string, string)> progr /// Create or load the file (if specified), with the Factorio data at and . /// /// The path to the data/ folder, containing the base and core folders. - /// The path to the mods/ folder, containing mod-list.json and the mods. Both zipped and unzipped mods are supported. May be empty (but not ) - /// to load only vanilla Factorio data. + /// The path to the mods/ folder, containing mod-list.json and the mods. Both zipped and unzipped mods are supported. + /// May be empty (but not ) to load only vanilla Factorio data. /// The path to the project file to create or load. May be or empty. /// Whether to use expensive recipes. - /// If , recipe selection windows will only display recipes that provide net production or consumption of the in question. + /// If , recipe selection windows will only display recipes that provide net production or consumption + /// of the in question. /// If , recipe selection windows will show all recipes that produce or consume any quantity of that .
/// For example, Kovarex enrichment will appear for both production and consumption of both U-235 and U-238 when , /// but will appear as only producing U-235 and consuming U-238 when . /// An that receives two strings describing the current loading state. /// An that will collect the errors and warnings encountered while loading and processing the file and data. - /// One of the languages supported by Factorio. Typically just the two-letter language code, e.g. en, but occasionally also includes the region code, e.g. pt-PT. + /// One of the languages supported by Factorio. Typically just the two-letter language code, e.g. en, + /// but occasionally also includes the region code, e.g. pt-PT. /// If , Yafc will render the icons necessary for UI display. - /// A containing the information loaded from . Also sets the properties in . + /// A containing the information loaded from . + /// Also sets the properties in . /// Thrown if a mod enabled in mod-list.json could not be found in . - public static Project Parse(string factorioPath, string modPath, string projectPath, bool expensive, bool netProduction, IProgress<(string MajorState, string MinorState)> progress, ErrorCollector errorCollector, string locale, bool renderIcons = true) { + public static Project Parse(string factorioPath, string modPath, string projectPath, bool expensive, bool netProduction, + IProgress<(string MajorState, string MinorState)> progress, ErrorCollector errorCollector, string locale, bool renderIcons = true) { + LuaContext? dataContext = null; + try { currentLoadingMod = null; string modSettingsPath = Path.Combine(modPath, "mod-settings.dat"); progress.Report(("Initializing", "Loading mod list")); string modListPath = Path.Combine(modPath, "mod-list.json"); Dictionary versionSpecifiers = []; + if (File.Exists(modListPath)) { var mods = JsonSerializer.Deserialize(File.ReadAllText(modListPath)) ?? throw new($"Could not read mod list from {modListPath}"); allMods = mods.mods.Where(x => x.enabled).Select(x => x.name).ToDictionary(x => x, x => (ModInfo)null!); @@ -176,15 +196,19 @@ public static Project Parse(string factorioPath, string modPath, string projectP List allFoundMods = []; FindMods(factorioPath, progress, allFoundMods); + if (modPath != factorioPath && modPath != "") { FindMods(modPath, progress, allFoundMods); } Version? factorioVersion = null; + foreach (var mod in allFoundMods) { currentLoadingMod = mod.name; + if (mod.name == "base") { mod.parsedFactorioVersion = mod.parsedVersion; + if (factorioVersion == null || mod.parsedVersion > factorioVersion) { factorioVersion = mod.parsedVersion; } @@ -193,6 +217,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP foreach (var mod in allFoundMods) { currentLoadingMod = mod.name; + if (mod.ValidForFactorioVersion(factorioVersion) && allMods.TryGetValue(mod.name, out var existing) && (existing == null || mod.parsedVersion > existing.parsedVersion || (mod.parsedVersion == existing.parsedVersion && existing.zipArchive != null && mod.zipArchive == null)) && (!versionSpecifiers.TryGetValue(mod.name, out var version) || mod.parsedVersion == version)) { existing?.Dispose(); allMods[mod.name] = mod; @@ -206,6 +231,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP foreach (var (name, mod) in allMods) { currentLoadingMod = name; + if (mod == null) { missingMod ??= name; logger.Error("Mod not found: {ModName}.", name); @@ -218,12 +244,13 @@ public static Project Parse(string factorioPath, string modPath, string projectP throw new NotSupportedException("Mod not found: " + missingMod + ". Try loading this pack in Factorio first."); } - List modsToDisable = []; do { modsToDisable.Clear(); + foreach (var (name, mod) in allMods) { currentLoadingMod = name; + if (!mod.CheckDependencies(allMods, modsToDisable)) { modsToDisable.Add(name); } @@ -248,8 +275,10 @@ public static Project Parse(string factorioPath, string modPath, string projectP List sortedMods = [.. modsToLoad]; sortedMods.Sort((a, b) => string.Compare(a, b, StringComparison.OrdinalIgnoreCase)); List currentLoadBatch = []; + while (modsToLoad.Count > 0) { currentLoadBatch.Clear(); + foreach (string mod in sortedMods) { if (allMods[mod].CanLoad(modsToLoad)) { currentLoadBatch.Add(mod); @@ -289,10 +318,10 @@ public static Project Parse(string factorioPath, string modPath, string projectP DataUtils.expensiveRecipes = expensive; DataUtils.netProduction = netProduction; - currentLoadingMod = null; dataContext = new LuaContext(); object? settings = null; + if (File.Exists(modSettingsPath)) { using (FileStream fs = new FileStream(Path.Combine(modPath, "mod-settings.dat"), FileMode.Open, FileAccess.Read)) { settings = FactorioPropertyTree.ReadModSettings(new BinaryReader(fs), dataContext); @@ -316,10 +345,12 @@ public static Project Parse(string factorioPath, string modPath, string projectP var project = deserializer.LoadData(projectPath, dataContext.data, (LuaTable)dataContext.defines["prototypes"]!, netProduction, progress, errorCollector, renderIcons); logger.Information("Completed!"); progress.Report(("Completed!", "")); + return project; } finally { dataContext?.Dispose(); + foreach (var mod in allMods) { mod.Value?.Dispose(); } @@ -371,8 +402,10 @@ public ModInfo(string folder, byte[] json, ZipArchive? zipArchive = null) { public void ParseDependencies() { foreach (string dependency in dependencies) { var match = DependencyRegex().Match(dependency); + if (match.Success) { string modifier = match.Groups[1].Value; + if (modifier == "!") { incompatibilities.Add(match.Groups[2].Value); continue; @@ -386,14 +419,10 @@ public void ParseDependencies() { } } - private bool MajorMinorEquals(Version a, Version b) { - return a.Major == b.Major && a.Minor == b.Minor; - } + private static bool MajorMinorEquals(Version a, Version b) => a.Major == b.Major && a.Minor == b.Minor; - public bool ValidForFactorioVersion(Version? factorioVersion) { - return factorioVersion == null || MajorMinorEquals(factorioVersion, parsedFactorioVersion) || - (MajorMinorEquals(factorioVersion, new Version(1, 0)) && MajorMinorEquals(parsedFactorioVersion, new Version(0, 18))) || name == "core"; - } + public bool ValidForFactorioVersion(Version? factorioVersion) => factorioVersion == null || MajorMinorEquals(factorioVersion, parsedFactorioVersion) || + (MajorMinorEquals(factorioVersion, new Version(1, 0)) && MajorMinorEquals(parsedFactorioVersion, new Version(0, 18))) || name == "core"; public bool CheckDependencies(Dictionary allMods, List modsToDisable) { foreach (var (mod, optional) in parsedDependencies) { @@ -434,8 +463,10 @@ public void Dispose() { public static IEnumerable GetAllModFiles(string mod, string prefix) { var info = allMods[mod]; + if (info.zipArchive != null) { prefix = info.folder + prefix; + foreach (var entry in info.zipArchive.Entries) { if (entry.FullName.StartsWith(prefix, StringComparison.Ordinal)) { yield return entry.FullName[info.folder.Length..]; @@ -444,6 +475,7 @@ public static IEnumerable GetAllModFiles(string mod, string prefix) { } else { string dirFrom = Path.Combine(info.folder, prefix); + if (Directory.Exists(dirFrom)) { foreach (string file in Directory.EnumerateFiles(dirFrom, "*", SearchOption.AllDirectories)) { yield return file[(info.folder.Length + 1)..]; diff --git a/Yafc.Parser/FactorioLocalization.cs b/Yafc.Parser/FactorioLocalization.cs index 939ae357..13f875f2 100644 --- a/Yafc.Parser/FactorioLocalization.cs +++ b/Yafc.Parser/FactorioLocalization.cs @@ -2,24 +2,29 @@ using System.IO; namespace Yafc.Parser; + internal static class FactorioLocalization { private static readonly Dictionary keys = []; public static void Parse(Stream stream) { using StreamReader reader = new StreamReader(stream); string category = ""; + while (true) { string? line = reader.ReadLine(); + if (line == null) { return; } line = line.Trim(); - if (line.StartsWith("[") && line.EndsWith("]")) { + + if (line.StartsWith('[') && line.EndsWith(']')) { category = line[1..^1]; } else { int idx = line.IndexOf('='); + if (idx < 0) { continue; } @@ -35,11 +40,13 @@ public static void Parse(Stream stream) { private static string CleanupTags(string source) { while (true) { int tagStart = source.IndexOf('['); + if (tagStart < 0) { return source; } int tagEnd = source.IndexOf(']', tagStart); + if (tagEnd < 0) { return source; } @@ -54,6 +61,7 @@ private static string CleanupTags(string source) { } int lastDash = key.LastIndexOf('-'); + if (lastDash > 0 && int.TryParse(key[(lastDash + 1)..], out int level) && keys.TryGetValue(key[..lastDash], out val)) { return val + " " + level; } diff --git a/Yafc.Parser/FactorioPropertyTree.cs b/Yafc.Parser/FactorioPropertyTree.cs index 4b359f67..6a1f108a 100644 --- a/Yafc.Parser/FactorioPropertyTree.cs +++ b/Yafc.Parser/FactorioPropertyTree.cs @@ -3,9 +3,11 @@ using System.Text; namespace Yafc.Parser; + internal static class FactorioPropertyTree { private static int ReadSpaceOptimizedUint(BinaryReader reader) { byte b = reader.ReadByte(); + if (b < 255) { return b; } @@ -20,43 +22,55 @@ private static string ReadString(BinaryReader reader) { int len = ReadSpaceOptimizedUint(reader); byte[] bytes = reader.ReadBytes(len); + return Encoding.UTF8.GetString(bytes); } public static object? ReadModSettings(BinaryReader reader, LuaContext context) { _ = reader.ReadInt64(); _ = reader.ReadBoolean(); + return ReadAny(reader, context); } private static object? ReadAny(BinaryReader reader, LuaContext context) { byte type = reader.ReadByte(); _ = reader.ReadByte(); + switch (type) { case 0: return null; + case 1: return reader.ReadBoolean(); + case 2: return reader.ReadDouble(); + case 3: return ReadString(reader); + case 4: int count = reader.ReadInt32(); var arr = context.NewTable(); + for (int i = 0; i < count; i++) { _ = ReadString(reader); arr[i + 1] = ReadAny(reader, context); } + return arr; + case 5: count = reader.ReadInt32(); var table = context.NewTable(); + for (int i = 0; i < count; i++) { table[ReadString(reader)] = ReadAny(reader, context); } return table; + default: throw new NotSupportedException("Unknown type"); } diff --git a/Yafc.Parser/LuaContext.cs b/Yafc.Parser/LuaContext.cs index 0c2b2710..ce7f583f 100644 --- a/Yafc.Parser/LuaContext.cs +++ b/Yafc.Parser/LuaContext.cs @@ -10,6 +10,7 @@ [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Yafc.Model.Tests")] namespace Yafc.Parser; + public class LuaException(string luaMessage) : Exception(luaMessage) { } internal partial class LuaContext : IDisposable { @@ -36,7 +37,6 @@ private enum Type { LUA_TTHREAD = 8, } - private const int LUA_REFNIL = -1; private const int REGISTRY = -1001000; [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate int LuaCFunction(IntPtr lua); @@ -133,7 +133,6 @@ private enum Type { [UnmanagedCallConv(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] private static partial void lua_settop(IntPtr state, int idx); - private IntPtr L; private readonly int tracebackReg; private readonly List<(string mod, string name)> fullChunkNames = []; @@ -150,6 +149,7 @@ public LuaContext() { _ = lua_pushstring(L, Project.currentYafcVersion.ToString()); lua_setglobal(L, "yafc_version"); var mods = NewTable(); + foreach (var mod in FactorioDataSource.allMods) { mods[mod.Key] = mod.Value.version; } @@ -160,6 +160,7 @@ public LuaContext() { neverCollect.Add(traceback); lua_pushcclosure(L, Marshal.GetFunctionPointerForDelegate(traceback), 0); tracebackReg = luaL_ref(L, REGISTRY); + if (Directory.Exists("Data/Mod-fixes/")) { foreach (string file in Directory.EnumerateFiles("Data/Mod-fixes/", "*.lua")) { string fileName = Path.GetFileName(file); @@ -172,9 +173,11 @@ public LuaContext() { private static int ParseTracebackEntry(string s, out int endOfName) { endOfName = 0; + if (s.StartsWith("[string \"", StringComparison.Ordinal)) { int endOfNum = s.IndexOf(' ', 9); endOfName = s.IndexOf("\"]:", 9, StringComparison.Ordinal) + 2; + if (endOfNum >= 0 && endOfName >= 0) { return int.Parse(s[9..endOfNum]); } @@ -188,8 +191,10 @@ private int CreateErrorTraceback(IntPtr lua) { luaL_traceback(L, L, message, 0); string actualTraceback = GetString(-1); string[] split = [.. actualTraceback.Split("\n\t")]; + for (int i = 0; i < split.Length; i++) { int chunkId = ParseTracebackEntry(split[i], out int endOfName); + if (chunkId >= 0) { split[i] = fullChunkNames[chunkId] + split[i][endOfName..]; } @@ -197,6 +202,7 @@ private int CreateErrorTraceback(IntPtr lua) { string reassemble = string.Join("\n", split); _ = lua_pushstring(L, reassemble); + return 1; } @@ -204,21 +210,19 @@ private int Log(IntPtr lua) { logger.Information(GetString(1)); return 0; } - private void GetReg(int refId) { - lua_rawgeti(L, REGISTRY, refId); - } + private void GetReg(int refId) => lua_rawgeti(L, REGISTRY, refId); - private void Pop(int popc) { - lua_settop(L, lua_gettop(L) - popc); - } + private void Pop(int popc) => lua_settop(L, lua_gettop(L) - popc); public List ArrayElements(int refId) { GetReg(refId); // 1 lua_pushnil(L); List list = []; + while (lua_next(L, -2) != 0) { object? value = PopManagedValue(1); object? key = PopManagedValue(0); + if (key is double) { list.Add(value); } @@ -226,7 +230,9 @@ private void Pop(int popc) { break; } } + Pop(1); + return list; } @@ -234,6 +240,7 @@ private void Pop(int popc) { GetReg(refId); // 1 lua_pushnil(L); Dictionary dict = []; + while (lua_next(L, -2) != 0) { object? value = PopManagedValue(1); object? key = PopManagedValue(0); @@ -241,7 +248,9 @@ private void Pop(int popc) { dict[key] = value; } } + Pop(1); + return dict; } @@ -274,6 +283,7 @@ public void SetGlobal(string name, object value) { private object? PopManagedValue(int popc) { object? result = null; + switch (lua_type(L, -1)) { case Type.LUA_TBOOLEAN: result = lua_toboolean(L, -1) != 0; @@ -287,6 +297,7 @@ public void SetGlobal(string name, object value) { case Type.LUA_TTABLE: int refId = luaL_ref(L, REGISTRY); LuaTable table = new LuaTable(this, refId); + if (popc == 0) { GetReg(table.refId); } @@ -348,6 +359,7 @@ private static string GetDirectoryName(string s) { private int Require(IntPtr lua) { string file = GetString(1); // 1 string argument = file; + if (file.Contains("..")) { throw new NotSupportedException("Attempt to traverse to parent directory"); } @@ -366,16 +378,18 @@ private int Require(IntPtr lua) { string tracebackS = GetString(-1); string[] tracebackVal = tracebackS.Split("\n\t"); int traceId = -1; + foreach (string traceLine in tracebackVal) // TODO slightly hacky { traceId = ParseTracebackEntry(traceLine, out _); + if (traceId >= 0) { break; } } var (mod, source) = fullChunkNames[traceId]; - (string mod, string path) requiredFile = (mod, fileExt); + if (file.StartsWith("__")) { requiredFile = FactorioDataSource.ResolveModPath(mod, origFile, true); } @@ -406,17 +420,21 @@ private int Require(IntPtr lua) { GetReg(value); return 1; } + logger.Information("Require {RequiredFile}", requiredFile.mod + "/" + requiredFile.path); byte[] bytes = FactorioDataSource.ReadModFile(requiredFile.mod, requiredFile.path); + if (bytes != null) { _ = lua_pushstring(L, argument); int argumentReg = luaL_ref(L, REGISTRY); int result = Exec(bytes, requiredFile.mod, requiredFile.path, argumentReg); + if (modFixes.TryGetValue(requiredFile, out byte[]? fix)) { string modFixName = "mod-fix-" + requiredFile.mod + "." + requiredFile.path; logger.Information("Running mod-fix {ModFix}", modFixName); result = Exec(fix, "*", modFixName, result); } + required[argument] = result; GetReg(result); } @@ -437,12 +455,11 @@ private byte[] GetData(int index) { nint ptr = lua_tolstring(L, index, out nint len); byte[] buf = new byte[(int)len]; Marshal.Copy(ptr, buf, 0, buf.Length); + return buf; } - private string GetString(int index) { - return Encoding.UTF8.GetString(GetData(index)); - } + private string GetString(int index) => Encoding.UTF8.GetString(GetData(index)); public int Exec(ReadOnlySpan chunk, string mod, string name, int argument = 0) { // since lua cuts file name to a few dozen symbols, add index to start of every name @@ -452,16 +469,20 @@ public int Exec(ReadOnlySpan chunk, string mod, string name, int argument chunk = chunk.CleanupBom(); var result = luaL_loadbufferx(L, in chunk.GetPinnableReference(), chunk.Length, name, null); + if (result != Result.LUA_OK) { throw new LuaException("Loading terminated with code " + result + "\n" + GetString(-1)); } int argcount = 0; + if (argument > 0) { GetReg(argument); argcount = 1; } + result = lua_pcallk(L, argcount, 1, -2 - argcount, IntPtr.Zero, IntPtr.Zero); + if (result != Result.LUA_OK) { if (result == Result.LUA_ERRRUN) { throw new LuaException(GetString(-1)); @@ -479,11 +500,13 @@ public void Dispose() { public void DoModFiles(string[] modorder, string fileName, IProgress<(string, string)> progress) { string header = "Executing mods " + fileName; + foreach (string mod in modorder) { required.Clear(); FactorioDataSource.currentLoadingMod = mod; progress.Report((header, mod)); byte[] bytes = FactorioDataSource.ReadModFile(mod, fileName); + if (bytes == null) { continue; } diff --git a/Yafc.Parser/SoftwareScaler.cs b/Yafc.Parser/SoftwareScaler.cs index 49ebb6dd..e3600d29 100644 --- a/Yafc.Parser/SoftwareScaler.cs +++ b/Yafc.Parser/SoftwareScaler.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc.Parser; + internal static class SoftwareScaler { public static unsafe IntPtr DownscaleIcon(IntPtr surface, int targetSize) { ref var surfaceData = ref RenderingUtils.AsSdlSurface(surface); @@ -17,16 +18,21 @@ public static unsafe IntPtr DownscaleIcon(IntPtr surface, int targetSize) { int bpp = Math.Min(pitch / surfaceData.w, targetSurfaceData.pitch / targetSurfaceData.w); int fromY = 0; int* buf = stackalloc int[bpp]; + for (int y = 0; y < targetSize; y++) { int toY = (y + 1) * sourceSize / targetSize; int fromX = 0; + for (int x = 0; x < targetSize; x++) { int toX = (x + 1) * sourceSize / targetSize; int c = 0; + for (int sy = fromY; sy < toY; sy++) { byte* pixels = (byte*)(surfaceData.pixels + (sy * pitch) + (fromX * bpp)); + for (int sx = fromX; sx < toX; sx++) { ++c; + for (int p = 0; p < bpp; p++) { buf[p] += *pixels; pixels++; @@ -35,6 +41,7 @@ public static unsafe IntPtr DownscaleIcon(IntPtr surface, int targetSize) { } byte* targetPixels = (byte*)(targetSurfaceData.pixels + (y * targetSurfaceData.pitch) + (x * bpp)); + for (int p = 0; p < bpp; p++) { int sum = buf[p]; *targetPixels = (byte)MathUtils.Clamp((float)sum / c, 0, 255); @@ -49,6 +56,7 @@ public static unsafe IntPtr DownscaleIcon(IntPtr surface, int targetSize) { } SDL.SDL_FreeSurface(surface); + return targetSurface; } } diff --git a/Yafc.UI/Core/DrawingSurface.cs b/Yafc.UI/Core/DrawingSurface.cs index 7794f928..751b1ec5 100644 --- a/Yafc.UI/Core/DrawingSurface.cs +++ b/Yafc.UI/Core/DrawingSurface.cs @@ -3,6 +3,7 @@ using SDL2; namespace Yafc.UI; + public readonly struct TextureHandle(DrawingSurface surface, IntPtr handle) { public readonly IntPtr handle = handle; public readonly DrawingSurface surface = surface; @@ -11,7 +12,7 @@ public readonly struct TextureHandle(DrawingSurface surface, IntPtr handle) { public TextureHandle Destroy() { if (valid) { - var capturedHandle = handle; + nint capturedHandle = handle; Ui.DispatchInMainThread(_ => SDL.SDL_DestroyTexture(capturedHandle), null); } return default; @@ -48,6 +49,7 @@ public TextureHandle BeginRenderToTexture(out SDL.SDL_Rect textureSize) { textureSize = new SDL.SDL_Rect { w = w, h = h }; nint texture = SDL.SDL_CreateTexture(renderer, SDL.SDL_PIXELFORMAT_RGBA8888, (int)SDL.SDL_TextureAccess.SDL_TEXTUREACCESS_TARGET, textureSize.w, textureSize.h); _ = SDL.SDL_SetRenderTarget(renderer, texture); + return new TextureHandle(this, texture); } @@ -57,6 +59,7 @@ public virtual SDL.SDL_Rect SetClip(SDL.SDL_Rect clip) { var prev = clipRect; clipRect = clip; _ = SDL.SDL_RenderSetClipRect(renderer, ref clip); + return prev; } @@ -73,9 +76,7 @@ public void Clear(SDL.SDL_Rect clipRect) { public TextureHandle CreateTextureFromSurface(IntPtr surface) => new TextureHandle(this, SDL.SDL_CreateTextureFromSurface(renderer, surface)); - public TextureHandle CreateTexture(uint format, int access, int w, int h) { - return new TextureHandle(this, SDL.SDL_CreateTexture(renderer, format, access, w, h)); - } + public TextureHandle CreateTexture(uint format, int access, int w, int h) => new TextureHandle(this, SDL.SDL_CreateTexture(renderer, format, access, w, h)); protected virtual void Dispose(bool disposing) { if (!disposedValue) { @@ -124,6 +125,7 @@ internal override void DrawBorder(SDL.SDL_Rect position, RectangleBorder border) RenderingUtils.GetBorderParameters(pixelsPerUnit, border, out int top, out int side, out int bottom); RenderingUtils.GetBorderBatch(position, top, side, bottom, ref blitMapping); var bm = blitMapping; + for (int i = 0; i < bm.Length; i++) { ref var cur = ref bm[i]; _ = SDL.SDL_BlitScaled(RenderingUtils.CircleSurface, ref cur.texture, surface, ref cur.position); diff --git a/Yafc.UI/Core/ExceptionScreen.cs b/Yafc.UI/Core/ExceptionScreen.cs index e3db5ebb..18b25fa6 100644 --- a/Yafc.UI/Core/ExceptionScreen.cs +++ b/Yafc.UI/Core/ExceptionScreen.cs @@ -3,6 +3,7 @@ using Serilog; namespace Yafc.UI; + public class ExceptionScreen : WindowUtility { private static readonly ILogger logger = Logging.GetLogger(); private static bool exists; @@ -10,6 +11,7 @@ public class ExceptionScreen : WindowUtility { public static void ShowException(Exception ex) { logger.Error(ex, "Exception encountered"); + if (!exists && !ignoreAll) { exists = true; Ui.DispatchInMainThread(state => new ExceptionScreen(ex), null); @@ -37,6 +39,7 @@ protected override void BuildContents(ImGui gui) { gui.BuildText(ex.GetType().Name, Font.header); gui.BuildText(ex.Message, new TextBlockDisplayStyle(Font.subheader, true)); gui.BuildText(ex.StackTrace, TextBlockDisplayStyle.WrappedText); + using (gui.EnterRow(0.5f, RectAllocator.RightRow)) { if (gui.BuildButton("Close")) { Close(); diff --git a/Yafc.UI/Core/IconCollection.cs b/Yafc.UI/Core/IconCollection.cs index b6230aae..0f3b01f3 100644 --- a/Yafc.UI/Core/IconCollection.cs +++ b/Yafc.UI/Core/IconCollection.cs @@ -3,6 +3,7 @@ using SDL2; namespace Yafc.UI; + public static class IconCollection { public const int IconSize = 32; public static SDL.SDL_Rect IconRect = new SDL.SDL_Rect { w = IconSize, h = IconSize }; @@ -12,6 +13,7 @@ public static class IconCollection { static IconCollection() { icons.Add(IntPtr.Zero); var iconId = Icon.None + 1; + while (iconId != Icon.FirstCustom) { nint surface = SDL_image.IMG_Load("Data/Icons/" + iconId + ".png"); nint surfaceRgba = SDL.SDL_CreateRGBSurfaceWithFormat(0, IconSize, IconSize, 0, SDL.SDL_PIXELFORMAT_RGBA8888); @@ -28,6 +30,7 @@ static IconCollection() { public static Icon AddIcon(IntPtr surface) { Icon id = (Icon)icons.Count; ref var surfaceData = ref RenderingUtils.AsSdlSurface(surface); + if (surfaceData.w == IconSize && surfaceData.h == IconSize) { icons.Add(surface); } @@ -38,6 +41,7 @@ public static Icon AddIcon(IntPtr surface) { icons.Add(blit); SDL.SDL_FreeSurface(surface); } + return id; } @@ -45,6 +49,7 @@ public static Icon AddIcon(IntPtr surface) { public static void ClearCustomIcons() { int firstCustomIconId = (int)Icon.FirstCustom; + for (int i = firstCustomIconId; i < icons.Count; i++) { SDL.SDL_FreeSurface(icons[i]); } diff --git a/Yafc.UI/Core/InputSystem.cs b/Yafc.UI/Core/InputSystem.cs index 243441db..7a8c8b9e 100644 --- a/Yafc.UI/Core/InputSystem.cs +++ b/Yafc.UI/Core/InputSystem.cs @@ -4,6 +4,7 @@ using SDL2; namespace Yafc.UI; + public interface IKeyboardFocus { bool KeyDown(SDL.SDL_Keysym key); bool TextInput(string input); @@ -69,6 +70,7 @@ public void SetMouseFocus(IMouseFocus? mouseFocus) { public IKeyboardFocus? SetDefaultKeyboardFocus(IKeyboardFocus? focus) { IKeyboardFocus? previousFocus = defaultKeyboardFocus; defaultKeyboardFocus = focus; + return previousFocus; } @@ -77,6 +79,7 @@ public void SetMouseFocus(IMouseFocus? mouseFocus) { internal void KeyDown(SDL.SDL_Keysym key) { keyMod = key.mod; + if (activeKeyboardFocus == null || !activeKeyboardFocus.KeyDown(key)) { _ = (defaultKeyboardFocus?.KeyDown(key)); } @@ -84,6 +87,7 @@ internal void KeyDown(SDL.SDL_Keysym key) { internal void KeyUp(SDL.SDL_Keysym key) { keyMod = key.mod; + if (activeKeyboardFocus == null || !activeKeyboardFocus.KeyUp(key)) { _ = (defaultKeyboardFocus?.KeyUp(key)); } @@ -105,6 +109,7 @@ internal void MouseMove(int rawX, int rawY) { Vector2 newMousePos = new Vector2(rawX / mouseOverWindow.pixelsPerUnit, rawY / mouseOverWindow.pixelsPerUnit); mouseDelta = newMousePos - mousePosition; mousePosition = newMousePos; + if (mouseDownButton != -1 && mouseDownPanel != null) { mouseDownPanel.MouseMove(mouseDownButton); } @@ -125,6 +130,7 @@ internal void MouseExitWindow(Window window) { internal void Update() { var currentHovering = HitTest(); + if (currentHovering != hoveringPanel) { hoveringPanel?.MouseExit(); hoveringPanel = currentHovering; @@ -164,6 +170,7 @@ internal void MouseUp(int button) { mouseDownPosition = default; mouseDownButton = -1; + foreach (var mouseUp in mouseUpCallbacks) { Ui.DispatchInMainThread(mouseUp.Item1, mouseUp.Item2); } diff --git a/Yafc.UI/Core/Logging.cs b/Yafc.UI/Core/Logging.cs index 24fcf923..d82a2d67 100644 --- a/Yafc.UI/Core/Logging.cs +++ b/Yafc.UI/Core/Logging.cs @@ -31,6 +31,7 @@ static Logging() => configureLogger = configure => configure /// Thrown if has been called. public static void SetLoggerConfiguration(Action configureLogger) { ArgumentNullException.ThrowIfNull(configureLogger, nameof(configureLogger)); + if (!canChangeConfiguration) { throw new InvalidOperationException("Do not change configuration after getting the logger; it will have no effect."); } @@ -47,6 +48,7 @@ private static Logger CreateLogger() { canChangeConfiguration = false; LoggerConfiguration configuration = new LoggerConfiguration(); configureLogger(configuration); + return configuration.CreateLogger(); } @@ -58,10 +60,12 @@ public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory) if (logEvent.Level is LogEventLevel.Debug or LogEventLevel.Verbose) { for (int i = 1; ; i++) { StackTrace trace = new(i); // Null-forgive everything in StackTrace. + if (trace.GetFrame(0)!.GetMethod()!.DeclaringType!.Namespace!.StartsWith("Yafc")) { logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("StackTrace", trace)); break; } + if (trace.FrameCount == 0) { logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("StackTrace", new StackTrace())); break; diff --git a/Yafc.UI/Core/MathUtils.cs b/Yafc.UI/Core/MathUtils.cs index d2484a01..f6690989 100644 --- a/Yafc.UI/Core/MathUtils.cs +++ b/Yafc.UI/Core/MathUtils.cs @@ -1,6 +1,7 @@ using System; namespace Yafc.UI; + public static class MathUtils { public static float Clamp(float value, float min, float max) { if (value < min) { @@ -71,6 +72,7 @@ public static float LinearToLogarithmic(float value, float logMin, float logMax, } float logCur = logMin + ((logMax - logMin) * value); + return MathF.Exp(logCur); } } diff --git a/Yafc.UI/Core/Rect.cs b/Yafc.UI/Core/Rect.cs index c92aeac5..9f376bf1 100644 --- a/Yafc.UI/Core/Rect.cs +++ b/Yafc.UI/Core/Rect.cs @@ -2,9 +2,10 @@ using System.Numerics; namespace Yafc.UI; -public struct Rect { - public float X, Y; - public float Width, Height; + +public struct Rect(float x, float y, float width, float height) { + public float X = x, Y = y; + public float Width = width, Height = height; public float Right { readonly get => X + Width; @@ -36,13 +37,6 @@ public float Top { public Rect(Vector2 position, Vector2 size) : this(position.X, position.Y, size.X, size.Y) { } - public Rect(float x, float y, float width, float height) { - X = x; - Y = y; - Width = width; - Height = height; - } - public static Rect SideRect(float left, float right, float top, float bottom) => new Rect(left, top, right - left, bottom - top); public static Rect SideRect(Vector2 topLeft, Vector2 bottomRight) => SideRect(topLeft.X, bottomRight.X, topLeft.Y, bottomRight.Y); @@ -50,7 +44,7 @@ public Rect(float x, float y, float width, float height) { public static Rect Union(Rect a, Rect b) => SideRect(MathF.Min(a.X, a.X), MathF.Max(a.Right, b.Right), MathF.Min(a.Y, b.Y), MathF.Max(a.Bottom, b.Bottom)); public Vector2 Size { - get => new Vector2(Width, Height); + readonly get => new Vector2(Width, Height); set { Width = value.X; Height = value.Y; @@ -58,7 +52,7 @@ public Vector2 Size { } public Vector2 Position { - get => new Vector2(X, Y); + readonly get => new Vector2(X, Y); set { X = value.X; Y = value.Y; @@ -69,11 +63,11 @@ public Vector2 Position { public readonly Rect LeftPart(float width) => new Rect(X, Y, width, Height); - public Vector2 TopLeft => new Vector2(X, Y); - public Vector2 TopRight => new Vector2(Right, Y); - public Vector2 BottomRight => new Vector2(Right, Bottom); - public Vector2 BottomLeft => new Vector2(X, Bottom); - public Vector2 Center => new Vector2(X + (Width * 0.5f), Y + (Height * 0.5f)); + public readonly Vector2 TopLeft => new Vector2(X, Y); + public readonly Vector2 TopRight => new Vector2(Right, Y); + public readonly Vector2 BottomRight => new Vector2(Right, Bottom); + public readonly Vector2 BottomLeft => new Vector2(X, Bottom); + public readonly Vector2 Center => new Vector2(X + (Width * 0.5f), Y + (Height * 0.5f)); public readonly bool Contains(Vector2 position) => position.X >= X && position.Y >= Y && position.X <= Right && position.Y <= Bottom; @@ -84,12 +78,14 @@ public Vector2 Position { public static Rect Intersect(Rect a, Rect b) { float left = MathF.Max(a.X, b.X); float right = MathF.Min(a.Right, b.Right); + if (right <= left) { return default; } float top = MathF.Max(a.Y, b.Y); float bottom = MathF.Min(a.Bottom, b.Bottom); + if (bottom <= top) { return default; } @@ -99,14 +95,15 @@ public static Rect Intersect(Rect a, Rect b) { public readonly bool Equals(Rect other) => this == other; - public override bool Equals(object? obj) => obj is Rect other && Equals(other); + public override readonly bool Equals(object? obj) => obj is Rect other && Equals(other); - public override int GetHashCode() { + public override readonly int GetHashCode() { unchecked { int hashCode = X.GetHashCode(); hashCode = (hashCode * 397) ^ Y.GetHashCode(); hashCode = (hashCode * 397) ^ Width.GetHashCode(); hashCode = (hashCode * 397) ^ Height.GetHashCode(); + return hashCode; } } @@ -121,7 +118,7 @@ public override int GetHashCode() { public static bool operator !=(in Rect a, in Rect b) => !(a == b); - public override string ToString() => "(" + X + "-" + Right + ")-(" + Y + "-" + Bottom + ")"; + public override readonly string ToString() => "(" + X + "-" + Right + ")-(" + Y + "-" + Bottom + ")"; public readonly Rect Expand(float amount) => new Rect(X - amount, Y - amount, Width + (2 * amount), Height + (2 * amount)); diff --git a/Yafc.UI/Core/SearchQuery.cs b/Yafc.UI/Core/SearchQuery.cs index 97d3da80..f9bca669 100644 --- a/Yafc.UI/Core/SearchQuery.cs +++ b/Yafc.UI/Core/SearchQuery.cs @@ -1,6 +1,7 @@ using System; namespace Yafc.UI; + public readonly struct SearchQuery(string query) { public readonly string query = query; public readonly string[] tokens = string.IsNullOrWhiteSpace(query) ? [] : query.Split(' ', StringSplitOptions.RemoveEmptyEntries); diff --git a/Yafc.UI/Core/Structs.cs b/Yafc.UI/Core/Structs.cs index f784f0e3..bf9415d1 100644 --- a/Yafc.UI/Core/Structs.cs +++ b/Yafc.UI/Core/Structs.cs @@ -1,4 +1,5 @@ namespace Yafc.UI; + public enum SchemeColor { // Special colors None, diff --git a/Yafc.UI/Core/TaskWindow.cs b/Yafc.UI/Core/TaskWindow.cs index ba3b74c4..5e5034a0 100644 --- a/Yafc.UI/Core/TaskWindow.cs +++ b/Yafc.UI/Core/TaskWindow.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; namespace Yafc.UI; + public abstract class TaskWindow : WindowUtility { private TaskCompletionSource? tcs; diff --git a/Yafc.UI/Core/Ui.cs b/Yafc.UI/Core/Ui.cs index 3ffa6d29..8c254f4a 100644 --- a/Yafc.UI/Core/Ui.cs +++ b/Yafc.UI/Core/Ui.cs @@ -9,7 +9,8 @@ using Serilog; namespace Yafc.UI; -public static class Ui { + +public static partial class Ui { private static readonly ILogger logger = Logging.GetLogger(typeof(Ui)); public static bool quit { get; private set; } @@ -17,8 +18,9 @@ public static class Ui { private static readonly Dictionary windows = []; internal static void RegisterWindow(uint id, Window window) => windows[id] = window; - [DllImport("SHCore.dll", SetLastError = true)] - private static extern bool SetProcessDpiAwareness(int awareness); + [LibraryImport("SHCore.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool SetProcessDpiAwareness(int awareness); public static void Start() { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { try { @@ -28,6 +30,7 @@ public static void Start() { logger.Information("DPI awareness setup failed"); // On older versions on Windows } } + _ = SDL.SDL_Init(SDL.SDL_INIT_VIDEO); _ = SDL.SDL_SetHint(SDL.SDL_HINT_RENDER_SCALE_QUALITY, "linear"); SDL.SDL_EnableScreenSaver(); @@ -68,6 +71,7 @@ public static void ProcessEvents() { var inputSystem = InputSystem.Instance; long minNextEvent = long.MaxValue - 1; time = timeWatch.ElapsedMilliseconds; + foreach (var (_, window) in windows) { minNextEvent = Math.Min(minNextEvent, window.nextRepaintTime); } @@ -81,6 +85,7 @@ public static void ProcessEvents() { case SDL.SDL_EventType.SDL_QUIT: if (!quit) { quit = true; + foreach (var (_, v) in windows) { if (v.preventQuit) { quit = false; @@ -88,6 +93,7 @@ public static void ProcessEvents() { } } } + break; case SDL.SDL_EventType.SDL_MOUSEBUTTONUP: inputSystem.MouseUp(evt.button.button); @@ -97,6 +103,7 @@ public static void ProcessEvents() { break; case SDL.SDL_EventType.SDL_MOUSEWHEEL: int y = -evt.wheel.y; + if (evt.wheel.direction == (uint)SDL.SDL_MouseWheelDirection.SDL_MOUSEWHEEL_FLIPPED) { y = -y; } @@ -115,6 +122,7 @@ public static void ProcessEvents() { case SDL.SDL_EventType.SDL_TEXTINPUT: unsafe { int term = 0; + while (evt.text.text[term] != 0) { ++term; } @@ -182,6 +190,7 @@ public static void ProcessEvents() { hasEvents = SDL.SDL_PollEvent(out evt) != 0; } + time = timeWatch.ElapsedMilliseconds; RebuildTimedOutWindows(); inputSystem.Update(); @@ -215,8 +224,10 @@ public static void Quit() { private static void ProcessAsyncCallbackQueue() { bool hasCustomCallbacks = true; + while (hasCustomCallbacks) { (SendOrPostCallback, object?) next; + lock (CallbacksQueued) { if (CallbacksQueued.Count == 0) { break; @@ -237,6 +248,7 @@ private static void ProcessAsyncCallbackQueue() { public static void DispatchInMainThread(SendOrPostCallback callback, object? data) { bool shouldSendEvent = false; + lock (CallbacksQueued) { if (CallbacksQueued.Count == 0) { shouldSendEvent = true; @@ -258,6 +270,7 @@ public static void DispatchInMainThread(SendOrPostCallback callback, object? dat public static void UnregisterWindow(Window window) { _ = windows.Remove(window.id); + if (windows.Count == 0) { Quit(); } diff --git a/Yafc.UI/Core/UiSynchronizationContext.cs b/Yafc.UI/Core/UiSynchronizationContext.cs index 0c949d5a..38f29332 100644 --- a/Yafc.UI/Core/UiSynchronizationContext.cs +++ b/Yafc.UI/Core/UiSynchronizationContext.cs @@ -3,6 +3,7 @@ using System.Threading; namespace Yafc.UI; + public class UiSynchronizationContext : SynchronizationContext { public override void Post(SendOrPostCallback d, object? state) => Ui.DispatchInMainThread(d, state); @@ -13,6 +14,7 @@ private class SendCommand(SendOrPostCallback d, object? state) { public static void Call(object state) { SendCommand send = (SendCommand)state; + try { send.d(send.state); } @@ -28,10 +30,12 @@ public static void Call(object state) { public override void Send(SendOrPostCallback d, object? state) { SendCommand send = new SendCommand(d, state); + lock (send) { Post(SendCommand.Call!, send); // null-forgiving: send is not null, so Call doesn't need to accept a null. _ = Monitor.Wait(send); } + if (send.ex != null) { throw send.ex; } diff --git a/Yafc.UI/Core/Window.cs b/Yafc.UI/Core/Window.cs index 6cb02eb0..685d9492 100644 --- a/Yafc.UI/Core/Window.cs +++ b/Yafc.UI/Core/Window.cs @@ -4,6 +4,7 @@ using Serilog; namespace Yafc.UI; + public abstract class Window : IDisposable { private static readonly ILogger logger = Logging.GetLogger(); @@ -36,7 +37,9 @@ public abstract class Window : IDisposable { internal Window(Padding padding) => rootGui = new ImGui(Build, padding); internal void Create() { - if (surface is null) { throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(Create)}."); } + if (surface is null) { + throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(Create)}."); + } SDL.SDL_SetWindowIcon(window, GetIcon()); @@ -51,6 +54,7 @@ internal void Create() { internal static IntPtr GetIcon() { if (icon == IntPtr.Zero) { icon = SDL_image.IMG_Load("image.ico"); + if (icon == IntPtr.Zero) { string error = SDL.SDL_GetError(); logger.Warning("Failed to load application icon: {error}", error); @@ -60,11 +64,12 @@ internal static IntPtr GetIcon() { return icon; } - internal int CalculateUnitsToPixels(int display) { + internal static int CalculateUnitsToPixels(int display) { _ = SDL.SDL_GetDisplayDPI(display, out float dpi, out _, out _); _ = SDL.SDL_GetDisplayBounds(display, out var rect); // 82x60 is the minimum screen size in units, plus some for borders int desiredUnitsToPixels = dpi == 0 ? 13 : MathUtils.Round(dpi / 6.8f); + if (desiredUnitsToPixels * 82f >= rect.w) { desiredUnitsToPixels = MathUtils.Floor(rect.w / 82f); } @@ -86,6 +91,7 @@ internal void WindowMoved() { int index = SDL.SDL_GetWindowDisplayIndex(window); int u2p = CalculateUnitsToPixels(index); + if (u2p != pixelsPerUnit) { pixelsPerUnit = u2p; surface.pixelsPerUnit = pixelsPerUnit; @@ -98,7 +104,9 @@ internal void WindowMoved() { protected virtual void OnRepaint() { } internal void Render() { - if (surface is null) { throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(Render)}."); } + if (surface is null) { + throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(Render)}."); + } if (!repaintRequired && nextRepaintTime > Ui.time) { return; @@ -110,6 +118,7 @@ internal void Render() { OnRepaint(); repaintRequired = false; + if (rootGui.IsRebuildRequired()) { _ = rootGui.CalculateState(size.X, pixelsPerUnit); } @@ -119,7 +128,9 @@ internal void Render() { } protected virtual void MainRender() { - if (surface is null) { throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(MainRender)}."); } + if (surface is null) { + throw new InvalidOperationException($"surface must be set by a derived class before calling {nameof(MainRender)}."); + } var bgColor = backgroundColor.ToSdlColor(); _ = SDL.SDL_SetRenderDrawColor(surface.renderer, bgColor.r, bgColor.g, bgColor.b, bgColor.a); @@ -163,7 +174,6 @@ private void Focus() { } } - public virtual void FocusLost() { } public virtual void Minimized() { } @@ -213,7 +223,9 @@ private void Build(ImGui gui) { dropDown = null; } } + draggingOverlay?.Build(gui); + if (tooltip != null) { tooltip.Build(gui); if (!tooltip.active) { diff --git a/Yafc.UI/Core/WindowMain.cs b/Yafc.UI/Core/WindowMain.cs index c2d60ace..90539b71 100644 --- a/Yafc.UI/Core/WindowMain.cs +++ b/Yafc.UI/Core/WindowMain.cs @@ -5,8 +5,9 @@ using Serilog; namespace Yafc.UI; + // Main window is resizable and hardware-accelerated unless forced to render via software by caller -public abstract class WindowMain : Window { +public abstract class WindowMain(Padding padding) : Window(padding) { protected void Create(string title, int display, float initialWidth, float initialHeight, bool maximized, bool forceSoftwareRenderer) { if (visible) { return; @@ -21,9 +22,11 @@ protected void Create(string title, int display, float initialWidth, float initi int initialWidthPixels = Math.Max(minWidth, MathUtils.Round(initialWidth * pixelsPerUnit)); int initialHeightPixels = Math.Max(minHeight, MathUtils.Round(initialHeight * pixelsPerUnit)); SDL.SDL_WindowFlags flags = SDL.SDL_WindowFlags.SDL_WINDOW_RESIZABLE | (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? 0 : SDL.SDL_WindowFlags.SDL_WINDOW_OPENGL); + if (maximized) { flags |= SDL.SDL_WindowFlags.SDL_WINDOW_MAXIMIZED; } + window = SDL.SDL_CreateWindow(title, SDL.SDL_WINDOWPOS_CENTERED_DISPLAY(display), SDL.SDL_WINDOWPOS_CENTERED_DISPLAY(display), @@ -59,8 +62,6 @@ protected bool IsMaximized { return flags.HasFlag(SDL.SDL_WindowFlags.SDL_WINDOW_MAXIMIZED); } } - - protected WindowMain(Padding padding) : base(padding) { } } internal class MainWindowDrawingSurface : DrawingSurface { @@ -92,24 +93,29 @@ internal class MainWindowDrawingSurface : DrawingSurface { /// /// The index of the selected render driver, including 0 (SDL autoselect) if no known-best driver exists on this machine. /// This value should be fed to the second argument of SDL_CreateRenderer() - private int PickRenderDriver(SDL.SDL_RendererFlags flags, bool forceSoftwareRenderer) { + private static int PickRenderDriver(SDL.SDL_RendererFlags flags, bool forceSoftwareRenderer) { nint numRenderDrivers = SDL.SDL_GetNumRenderDrivers(); logger.Debug($"Render drivers available: {numRenderDrivers}"); int selectedRenderDriver = 0; + for (int thisRenderDriver = 0; thisRenderDriver < numRenderDrivers; thisRenderDriver++) { nint res = SDL.SDL_GetRenderDriverInfo(thisRenderDriver, out SDL.SDL_RendererInfo rendererInfo); + if (res != 0) { string reason = SDL.SDL_GetError(); logger.Warning($"Render driver {thisRenderDriver} GetInfo failed: {res}: {reason}"); continue; } + // This is for some reason the one data structure that the dotnet library doesn't provide a native unmarshal for string? driverName = Marshal.PtrToStringAnsi(rendererInfo.name); + if (driverName is null) { logger.Warning($"Render driver {thisRenderDriver} has an empty name, cannot compare, skipping"); continue; } - logger.Debug($"Render driver {thisRenderDriver} is {driverName} flags 0x{rendererInfo.flags.ToString("X")}"); + + logger.Debug($"Render driver {thisRenderDriver} is {driverName} flags 0x{rendererInfo.flags:X}"); // SDL2 does actually have a fixed (from code) ordering of available render drivers, so doing a full list scan instead of returning // immediately is a bit paranoid, but paranoia comes well-recommended when dealing with graphics drivers @@ -122,8 +128,10 @@ private int PickRenderDriver(SDL.SDL_RendererFlags flags, bool forceSoftwareRend else { if ((rendererInfo.flags | (uint)flags) != rendererInfo.flags) { logger.Debug($"Render driver {driverName} flags do not cover requested flags {flags}, skipping"); + continue; } + if (driverName == "direct3d12") { logger.Debug($"Selecting render driver {thisRenderDriver} (DX12)"); selectedRenderDriver = thisRenderDriver; @@ -134,7 +142,9 @@ private int PickRenderDriver(SDL.SDL_RendererFlags flags, bool forceSoftwareRend } } } + logger.Debug($"Selected render driver index {selectedRenderDriver}"); + return selectedRenderDriver; } @@ -142,8 +152,7 @@ public MainWindowDrawingSurface(WindowMain window, bool forceSoftwareRenderer) : this.window = window; renderer = SDL.SDL_CreateRenderer(window.window, PickRenderDriver(SDL.SDL_RendererFlags.SDL_RENDERER_PRESENTVSYNC, forceSoftwareRenderer), SDL.SDL_RendererFlags.SDL_RENDERER_PRESENTVSYNC); - - nint result = SDL.SDL_GetRendererInfo(renderer, out SDL.SDL_RendererInfo info); + _ = SDL.SDL_GetRendererInfo(renderer, out SDL.SDL_RendererInfo info); logger.Information($"Driver: {SDL.SDL_GetCurrentVideoDriver()} Renderer: {Marshal.PtrToStringAnsi(info.name)}"); circleTexture = SDL.SDL_CreateTextureFromSurface(renderer, RenderingUtils.CircleSurface); byte colorMod = RenderingUtils.darkMode ? (byte)255 : (byte)0; @@ -156,6 +165,7 @@ internal override void DrawBorder(SDL.SDL_Rect position, RectangleBorder border) RenderingUtils.GetBorderParameters(pixelsPerUnit, border, out int top, out int side, out int bottom); RenderingUtils.GetBorderBatch(position, top, side, bottom, ref blitMapping); var bm = blitMapping; + for (int i = 0; i < bm.Length; i++) { ref var cur = ref bm[i]; _ = SDL.SDL_RenderCopy(renderer, circleTexture, ref cur.texture, ref cur.position); diff --git a/Yafc.UI/Core/WindowUtility.cs b/Yafc.UI/Core/WindowUtility.cs index 6331d895..db338cc7 100644 --- a/Yafc.UI/Core/WindowUtility.cs +++ b/Yafc.UI/Core/WindowUtility.cs @@ -2,6 +2,7 @@ using SDL2; namespace Yafc.UI; + // Utility window is not hardware-accelerated and auto-size (and not resizable) public abstract class WindowUtility(Padding padding) : Window(padding) { private int windowWidth, windowHeight; @@ -43,6 +44,7 @@ protected internal override void WindowResize() { private void CheckSizeChange() { int newWindowWidth = rootGui.UnitsToPixels(contentSize.X); int newWindowHeight = rootGui.UnitsToPixels(contentSize.Y); + if (windowWidth != newWindowWidth || windowHeight != newWindowHeight) { windowWidth = newWindowWidth; windowHeight = newWindowHeight; @@ -94,6 +96,7 @@ public override void Dispose() { public override void Present() { base.Present(); + if (surface != IntPtr.Zero) { _ = SDL.SDL_UpdateWindowSurface(window.window); } diff --git a/Yafc.UI/ImGui/DropDownPanel.cs b/Yafc.UI/ImGui/DropDownPanel.cs index 1541d6a8..13ad4495 100644 --- a/Yafc.UI/ImGui/DropDownPanel.cs +++ b/Yafc.UI/ImGui/DropDownPanel.cs @@ -1,6 +1,7 @@ using System.Numerics; namespace Yafc.UI; + public abstract class AttachedPanel { protected readonly ImGui contents; protected Rect sourceRect; @@ -30,8 +31,10 @@ public void Close() { public void Build(ImGui gui) { owner = gui; + if (source != null && gui.isBuilding) { var rect = source.TranslateRect(sourceRect, gui); + if (ShouldBuild(source, sourceRect, gui, rect)) { var contentSize = contents.CalculateState(width, gui.pixelsPerUnit); var position = CalculatePosition(gui, rect, contentSize); @@ -49,9 +52,9 @@ public void Build(ImGui gui) { protected abstract void BuildContents(ImGui gui); } -public abstract class DropDownPanel : AttachedPanel, IMouseFocus { +public abstract class DropDownPanel(Padding padding, float width) : AttachedPanel(padding, width), IMouseFocus { private bool focused; - protected DropDownPanel(Padding padding, float width) : base(padding, width) { } + protected override bool ShouldBuild(ImGui source, Rect sourceRect, ImGui parent, Rect parentRect) => focused; public override void SetFocus(ImGui source, Rect rect) { @@ -102,12 +105,14 @@ protected override Vector2 CalculatePosition(ImGui gui, Rect targetRect, Vector2 float targetY = targetRect.Bottom + contentSize.Y > size.Y && targetRect.Y >= contentSize.Y ? targetRect.Y - contentSize.Y : targetRect.Bottom; float x = MathUtils.Clamp(targetRect.X, 0, size.X - contentSize.X); float y = MathUtils.Clamp(targetY, 0, size.Y - contentSize.Y); + return new Vector2(x, y); } protected override void BuildContents(ImGui gui) { gui.boxColor = SchemeColor.PureBackground; gui.textColor = SchemeColor.BackgroundText; + if (builder != null) { builder.Invoke(gui); } @@ -121,6 +126,7 @@ public abstract class Tooltip : AttachedPanel { protected Tooltip(Padding padding, float width) : base(padding, width) => contents.mouseCapture = false; protected override bool ShouldBuild(ImGui source, Rect sourceRect, ImGui parent, Rect parentRect) { var window = source.window; + if (InputSystem.Instance.mouseOverWindow != window) { return false; } @@ -130,6 +136,7 @@ protected override bool ShouldBuild(ImGui source, Rect sourceRect, ImGui parent, protected override Vector2 CalculatePosition(ImGui gui, Rect targetRect, Vector2 contentSize) { float x, y; + if (targetRect.Bottom < 4) { y = MathUtils.Clamp(targetRect.Bottom, 0f, gui.contentSize.Y - contentSize.Y); x = MathUtils.Clamp(targetRect.X, 0f, gui.contentSize.X - contentSize.X); @@ -139,6 +146,7 @@ protected override Vector2 CalculatePosition(ImGui gui, Rect targetRect, Vector2 targetRect.X >= contentSize.X ? targetRect.X - contentSize.X : (gui.contentSize.X - contentSize.X) / 2; y = MathUtils.Clamp(targetRect.Y, 0f, gui.contentSize.Y - contentSize.Y); } + return new Vector2(x, y); } } diff --git a/Yafc.UI/ImGui/ImGui.cs b/Yafc.UI/ImGui/ImGui.cs index 698b10f4..f3a9de74 100644 --- a/Yafc.UI/ImGui/ImGui.cs +++ b/Yafc.UI/ImGui/ImGui.cs @@ -6,6 +6,7 @@ using SDL2; namespace Yafc.UI; + public enum ImGuiAction { Consumed, Build, @@ -52,6 +53,7 @@ public enum RectAllocator { public sealed partial class ImGui : IDisposable, IPanel { public ImGui(GuiBuilder? guiBuilder, Padding padding, RectAllocator defaultAllocator = RectAllocator.Stretch, bool clip = false) { this.guiBuilder = guiBuilder; + if (guiBuilder == null) { action = ImGuiAction.Build; } @@ -93,6 +95,7 @@ public Vector2 offset { set { screenRect -= (_offset - value); _offset = value; + if (mousePresent) { MouseMove(InputSystem.Instance.mouseDownButton); } @@ -112,6 +115,7 @@ public void Rebuild() { public void MarkEverythingForRebuild() { CheckMainThread(); rebuildRequested = true; + foreach (var sub in panels) { sub.data.MarkEverythingForRebuild(); } @@ -132,6 +136,7 @@ public Vector2 CalculateState(float width, float pixelsPerUnit) { this.pixelsPerUnit = pixelsPerUnit; BuildGui(width); } + return contentSize; } @@ -141,6 +146,7 @@ public void Present(DrawingSurface surface, Rect position, Rect screenClip, ImGu } pixelsPerUnit = surface.pixelsPerUnit; + if (IsRebuildRequired() || buildWidth != position.Width) { BuildGui(position.Width); } @@ -159,6 +165,7 @@ internal void InternalPresent(DrawingSurface surface, Rect position, Rect screen SDL.SDL_Rect prevClip = default; screenRect = (position * scale) + offset; var screenOffset = screenRect.Position; + if (clip) { prevClip = surface.SetClip(ToSdlRect(screenClip)); } @@ -166,13 +173,16 @@ internal void InternalPresent(DrawingSurface surface, Rect position, Rect screen localClip = new Rect(screenClip.Position - screenOffset, screenClip.Size / scale); SchemeColor currentColor = (SchemeColor)(-1); borders.Clear(); + for (int i = rects.Count - 1; i >= 0; i--) { var (rect, border, color) = rects[i]; + if (!rect.IntersectsWith(localClip)) { continue; } var sdlRect = ToSdlRect(rect, screenOffset); + if (border != RectangleBorder.None) { borders.Add((sdlRect, border)); } @@ -186,6 +196,7 @@ internal void InternalPresent(DrawingSurface surface, Rect position, Rect screen var sdlColor = currentColor.ToSdlColor(); _ = SDL.SDL_SetRenderDrawColor(renderer, sdlColor.r, sdlColor.g, sdlColor.b, sdlColor.a); } + _ = SDL.SDL_RenderFillRect(renderer, ref sdlRect); } @@ -212,6 +223,7 @@ internal void InternalPresent(DrawingSurface surface, Rect position, Rect screen foreach (var (rect, batch, _) in panels) { Rect intersection = Rect.Intersect(rect, localClip); + if (intersection == default) { continue; } @@ -226,8 +238,10 @@ internal void InternalPresent(DrawingSurface surface, Rect position, Rect screen public IPanel HitTest(Vector2 position) { position = (position / scale) - offset; + for (int i = panels.Count - 1; i >= 0; i--) { var (rect, panel, _) = panels[i]; + if (panel.mouseCapture && rect.Contains(position)) { return panel.HitTest(position - rect.Position); } @@ -288,11 +302,13 @@ public void Dispose() { ReleaseUnmanagedResources(); } - private void ExportDrawCommandsTo(List> sourceList, List> targetList, Rect rect) { + private static void ExportDrawCommandsTo(List> sourceList, List> targetList, Rect rect) { targetList.Clear(); var delta = rect.Position; + for (int i = sourceList.Count - 1; i >= 0; i--) { var elem = sourceList[i]; + if (rect.Contains(elem.rect)) { targetList.Add(new DrawCommand(elem.rect - delta, elem.data, elem.color)); } @@ -320,9 +336,7 @@ public void PropagateMessage(T message) { parent?.PropagateMessage(message); } - public void AddMessageHandler(Func handler) { - messageHandlers.Add(handler); - } + public void AddMessageHandler(Func handler) => messageHandlers.Add(handler); private readonly List messageHandlers = []; } diff --git a/Yafc.UI/ImGui/ImGuiBuildCache.cs b/Yafc.UI/ImGui/ImGuiBuildCache.cs index 8007aca7..ffeaaf36 100644 --- a/Yafc.UI/ImGui/ImGuiBuildCache.cs +++ b/Yafc.UI/ImGui/ImGuiBuildCache.cs @@ -2,17 +2,15 @@ using System.Diagnostics.CodeAnalysis; namespace Yafc.UI; + public partial class ImGui { - public class BuildGroup { - private readonly ImGui gui; + public class BuildGroup(ImGui gui) { private object? obj; private float left, right, top; private CopyableState state; private Rect lastRect; private bool finished; - public BuildGroup(ImGui gui) => this.gui = gui; - public void Update(object obj) { left = gui.state.left; right = gui.state.right; @@ -44,8 +42,10 @@ public void Skip() { public bool ShouldBuildGroup(object o, [MaybeNullWhen(false)] out BuildGroup group) { buildGroupsIndex++; BuildGroup current; + if (buildGroups.Count > buildGroupsIndex) { current = buildGroups[buildGroupsIndex]; + if (current.CanSkip(o)) { current.Skip(); group = null; @@ -58,6 +58,7 @@ public bool ShouldBuildGroup(object o, [MaybeNullWhen(false)] out BuildGroup gro } current.Update(o); group = current; + return true; } } diff --git a/Yafc.UI/ImGui/ImGuiBuilding.cs b/Yafc.UI/ImGui/ImGuiBuilding.cs index 99c6d9fc..e0652565 100644 --- a/Yafc.UI/ImGui/ImGuiBuilding.cs +++ b/Yafc.UI/ImGui/ImGuiBuilding.cs @@ -4,17 +4,13 @@ using SDL2; namespace Yafc.UI; + public partial class ImGui { - private readonly struct DrawCommand { - public readonly Rect rect; - public readonly T data; - public readonly SchemeColor color; + private readonly struct DrawCommand(Rect rect, T data, SchemeColor color) { + public readonly Rect rect = rect; + public readonly T data = data; + public readonly SchemeColor color = color; - public DrawCommand(Rect rect, T data, SchemeColor color) { - this.rect = rect; - this.data = data; - this.color = color; - } public void Deconstruct(out Rect rect, out T data, out SchemeColor color) { rect = this.rect; data = this.data; @@ -79,9 +75,7 @@ public void ManualDrawingClear() { public readonly ImGuiCache.Cache textCache = new ImGuiCache.Cache(); - public FontFile.FontSize GetFontSize(Font? font = null) { - return (font ?? Font.text).GetFontSize(pixelsPerUnit); - } + public FontFile.FontSize GetFontSize(Font? font = null) => (font ?? Font.text).GetFontSize(pixelsPerUnit); public SchemeColor textColor { get => state.textColor; @@ -91,11 +85,13 @@ public SchemeColor textColor { public void BuildText(string? text, TextBlockDisplayStyle? displayStyle = null, float topOffset = 0f, float maxWidth = float.MaxValue) { displayStyle ??= TextBlockDisplayStyle.Default(); SchemeColor color = displayStyle.Color; + if (color == SchemeColor.None) { color = state.textColor; } Rect rect = AllocateTextRect(out TextCache? cache, text, displayStyle, topOffset, maxWidth); + if (action == ImGuiAction.Build && cache != null) { DrawRenderable(rect, cache, color); } @@ -117,6 +113,7 @@ public Vector2 GetTextDimensions(out TextCache? cache, string? text, Font? font public Rect AllocateTextRect(out TextCache? cache, string? text, TextBlockDisplayStyle displayStyle, float topOffset = 0f, float maxWidth = float.MaxValue) { FontFile.FontSize fontSize = GetFontSize(displayStyle.Font); Rect rect; + if (string.IsNullOrEmpty(text)) { cache = null; rect = AllocateRect(0f, topOffset + (fontSize.lineSize / pixelsPerUnit)); @@ -141,6 +138,7 @@ public void DrawText(Rect rect, string text, RectAlignment alignment = RectAlign var fontSize = GetFontSize(font); var cache = textCache.GetCached((fontSize, text, uint.MaxValue)); var realRect = AlignRect(rect, alignment, cache.texRect.w / pixelsPerUnit, cache.texRect.h / pixelsPerUnit); + if (action == ImGuiAction.Build) { DrawRenderable(realRect, cache, color); } @@ -149,9 +147,11 @@ public void DrawText(Rect rect, string text, RectAlignment alignment = RectAlign private ImGuiTextInputHelper? textInputHelper; public bool BuildTextInput(string? text, out string newText, string? placeholder, Icon icon = Icon.None, bool delayed = false, bool setInitialFocus = false) { TextBoxDisplayStyle displayStyle = TextBoxDisplayStyle.DefaultTextInput; + if (icon != Icon.None) { displayStyle = displayStyle with { Icon = icon }; } + return BuildTextInput(text, out newText, placeholder, displayStyle, delayed, setInitialFocus); } @@ -159,6 +159,7 @@ public bool BuildTextInput(string? text, out string newText, string? placeholder setInitialFocus &= textInputHelper == null; textInputHelper ??= new ImGuiTextInputHelper(this); bool result = textInputHelper.BuildTextInput(text, out newText, placeholder, GetFontSize(), delayed, displayStyle); + if (setInitialFocus) { SetTextInputFocus(lastRect, ""); } @@ -172,6 +173,7 @@ public void BuildIcon(Icon icon, float size = 1.5f, SchemeColor color = SchemeCo } var rect = AllocateRect(size, size, RectAlignment.Middle); + if (action == ImGuiAction.Build) { DrawIcon(rect, icon, color); } @@ -199,16 +201,19 @@ private bool DoGui(ImGuiAction action) { guiBuilder(this); } actionParameter = 0; + if (action == ImGuiAction.Build) { return false; } bool consumed = this.action == ImGuiAction.Consumed; + if (IsRebuildRequired()) { BuildGui(buildWidth); } this.action = ImGuiAction.Consumed; + return consumed; } @@ -223,10 +228,12 @@ private void BuildGui(float width) { ClearDrawCommandList(); _ = DoGui(ImGuiAction.Build); contentSize = new Vector2(lastContentRect.Right, lastContentRect.Height); + if (boxColor != SchemeColor.None) { Rect rect = new Rect(default, contentSize); rects.Add(new DrawCommand(rect, boxShadow, boxColor)); } + textCache.PurgeUnused(); CollectCustomCache?.Invoke(); Repaint(); @@ -235,10 +242,12 @@ private void BuildGui(float width) { public void MouseMove(int mouseDownButton) { actionParameter = mouseDownButton; mousePresent = true; + if (currentDraggingObject != null) { _ = DoGui(ImGuiAction.MouseDrag); return; } + if (!mouseOverRect.Contains(mousePosition)) { mouseOverRect = Rect.VeryBig; rebuildRequested = true; @@ -265,6 +274,7 @@ public void MouseLost() { public void MouseUp(int button) { mouseDownButton = -1; + if (currentDraggingObject != null) { currentDraggingObject = null; rebuildRequested = true; @@ -282,6 +292,7 @@ public void MouseUp(int button) { public void MouseScroll(int delta) { actionParameter = delta; + if (!DoGui(ImGuiAction.MouseScroll)) { parent?.MouseScroll(delta); } @@ -289,6 +300,7 @@ public void MouseScroll(int delta) { public void MouseExit() { mousePresent = false; + if (mouseOverRect != Rect.VeryBig) { mouseOverRect = Rect.VeryBig; SDL.SDL_SetCursor(RenderingUtils.cursorArrow); @@ -302,6 +314,7 @@ public bool ConsumeMouseDown(Rect rect, uint button = SDL.SDL_BUTTON_LEFT, IntPt action = ImGuiAction.Consumed; rebuildRequested = true; mouseDownRect = rect; + if (cursor != default) { SDL.SDL_SetCursor(cursor); cursorSetByMouseDown = true; @@ -315,12 +328,14 @@ public bool ConsumeMouseDown(Rect rect, uint button = SDL.SDL_BUTTON_LEFT, IntPt public bool ConsumeMouseOver(Rect rect, IntPtr cursor = default, bool rebuild = true) { if (action == ImGuiAction.MouseMove && mousePresent && rect.Contains(mousePosition)) { action = ImGuiAction.Consumed; + if (mouseOverRect != rect) { if (rebuild) { rebuildRequested = true; } mouseOverRect = rect; + if (!cursorSetByMouseDown) { SDL.SDL_SetCursor(cursor == default ? RenderingUtils.cursorArrow : cursor); } diff --git a/Yafc.UI/ImGui/ImGuiCache.cs b/Yafc.UI/ImGui/ImGuiCache.cs index 3f7bd2f1..5477a98c 100644 --- a/Yafc.UI/ImGui/ImGuiCache.cs +++ b/Yafc.UI/ImGui/ImGuiCache.cs @@ -4,6 +4,7 @@ using SDL2; namespace Yafc.UI; + public abstract class ImGuiCache : IDisposable where T : ImGuiCache where TKey : IEquatable { private static readonly T Constructor = (T)RuntimeHelpers.GetUninitializedObject(typeof(T)); @@ -40,7 +41,6 @@ public void Dispose() { } } - protected abstract T CreateForKey(TKey key); public abstract void Dispose(); } diff --git a/Yafc.UI/ImGui/ImGuiDrag.cs b/Yafc.UI/ImGui/ImGuiDrag.cs index 8d934faa..10fb9f5b 100644 --- a/Yafc.UI/ImGui/ImGuiDrag.cs +++ b/Yafc.UI/ImGui/ImGuiDrag.cs @@ -3,6 +3,7 @@ using System.Numerics; namespace Yafc.UI; + public partial class ImGui { private object? currentDraggingObject; @@ -52,8 +53,10 @@ public bool ConsumeDrag(Vector2 anchor, T obj) { public bool DoListReordering(Rect moveHandle, Rect contents, T index, out T moveFrom, SchemeColor backgroundColor = SchemeColor.PureBackground, bool updateDraggingObject = true) { moveFrom = index; + if (!this.InitiateDrag(moveHandle, contents, index, backgroundColor) && action == ImGuiAction.MouseDrag && ConsumeDrag(contents.Center, index)) { moveFrom = (T)currentDraggingObject; + if (updateDraggingObject) { UpdateDraggingObject(index); } @@ -63,7 +66,6 @@ public bool DoListReordering(Rect moveHandle, Rect contents, T index, out T m return false; } - internal class DragOverlay { private readonly ImGui contents = new ImGui(null, default) { mouseCapture = false }; @@ -71,15 +73,16 @@ internal class DragOverlay { private Vector2 mouseOffset; private Rect realPosition; - public bool ShouldConsumeDrag(ImGui source, Vector2 point) => currentSource == source && realPosition.Contains(source.ToWindowPosition(point)); - private void ExtractDrawCommandsFrom(List> sourceList, List> targetList, Rect rect) { + private static void ExtractDrawCommandsFrom(List> sourceList, List> targetList, Rect rect) { targetList.Clear(); var delta = rect.Position; int firstInBlock = -1; + for (int i = 0; i < sourceList.Count; i++) { var elem = sourceList[i]; + if (rect.Contains(elem.rect)) { if (firstInBlock == -1) { firstInBlock = i; diff --git a/Yafc.UI/ImGui/ImGuiLayout.cs b/Yafc.UI/ImGui/ImGuiLayout.cs index e51fbdf0..ad4d49b5 100644 --- a/Yafc.UI/ImGui/ImGuiLayout.cs +++ b/Yafc.UI/ImGui/ImGuiLayout.cs @@ -2,6 +2,7 @@ using System.Numerics; namespace Yafc.UI; + public partial class ImGui { private CopyableState state; public Rect lastRect { get; set; } @@ -26,11 +27,13 @@ private void ResetLayout() { public Rect AllocateRect(float width, float height, float spacing = float.NegativeInfinity) { var rect = state.AllocateRect(width, height, spacing); lastRect = state.EncapsulateRect(rect); + return lastRect; } public Rect EncapsulateRect(Rect rect) { lastRect = state.EncapsulateRect(rect); + return lastRect; } @@ -55,6 +58,7 @@ public Rect AllocateRect(float width, float height, RectAlignment alignment, flo public ImGui RemainingRow(float spacing = float.NegativeInfinity) { state.AllocateSpacing(spacing); allocator = RectAllocator.RemainingRow; + return this; } @@ -62,6 +66,7 @@ public Context EnterGroup(Padding padding, RectAllocator allocator, SchemeColor state.AllocateSpacing(); Context ctx = new Context(this, padding); state.allocator = allocator; + if (!float.IsNegativeInfinity(spacing)) { state.spacing = spacing; } @@ -84,6 +89,7 @@ public Context EnterFixedPositioning(float width, float height, Padding padding, state.right = rect.Right; state.bottom = state.top = rect.Top; state.allocator = RectAllocator.Stretch; + if (textColor != SchemeColor.None) { state.textColor = textColor; } @@ -101,11 +107,13 @@ private struct CopyableState { public Rect AllocateRect(float width, float height, float spacing) { AllocateSpacing(spacing); + if (allocator != RectAllocator.LeftRow) { width = Math.Min(width, right - left); } float rowHeight = MathF.Max(height, bottom - top); + return allocator switch { RectAllocator.Stretch => new Rect(left, top, right - left, height), RectAllocator.LeftAlign => new Rect(left, top, width, height), @@ -149,6 +157,7 @@ public void AllocateSpacing(float amount = float.NegativeInfinity) { public Rect EncapsulateRect(Rect rect) { contextRect = hasContent ? Rect.Union(contextRect, rect) : rect; hasContent = true; + switch (allocator) { case RectAllocator.Stretch: top = bottom = MathF.Max(rect.Bottom, top); @@ -215,6 +224,7 @@ public void Dispose() { rect.Y -= padding.top; rect.Width += padding.left + padding.right; rect.Height += padding.top + padding.bottom; + if (hasContent) { gui.lastRect = gui.state.EncapsulateRect(rect); gui.lastContentRect = rect; diff --git a/Yafc.UI/ImGui/ImGuiTextInputHelper.cs b/Yafc.UI/ImGui/ImGuiTextInputHelper.cs index 71ef5c2e..b1ade4c5 100644 --- a/Yafc.UI/ImGui/ImGuiTextInputHelper.cs +++ b/Yafc.UI/ImGui/ImGuiTextInputHelper.cs @@ -4,11 +4,8 @@ using SDL2; namespace Yafc.UI; -internal class ImGuiTextInputHelper : IKeyboardFocus { - private readonly ImGui gui; - - public ImGuiTextInputHelper(ImGui gui) => this.gui = gui; +internal partial class ImGuiTextInputHelper(ImGui gui) : IKeyboardFocus { private string prevText = ""; private Rect prevRect; private string text = ""; @@ -22,6 +19,7 @@ internal class ImGuiTextInputHelper : IKeyboardFocus { public void SetFocus(Rect boundingRect, string setText) { setText ??= ""; + if (boundingRect == prevRect) { text = prevText; prevRect = default; @@ -30,18 +28,23 @@ public void SetFocus(Rect boundingRect, string setText) { editHistory.Clear(); text = setText; } + InputSystem.Instance.SetKeyboardFocus(this); rect = boundingRect; caret = selectionAnchor = setText.Length; } - private void GetTextParameters(string? textToBuild, Rect textRect, FontFile.FontSize fontSize, RectAlignment alignment, out TextCache? cachedText, out float scale, out float textWidth, out Rect realTextRect) { + private void GetTextParameters(string? textToBuild, Rect textRect, FontFile.FontSize fontSize, RectAlignment alignment, + out TextCache? cachedText, out float scale, out float textWidth, out Rect realTextRect) { + realTextRect = textRect; scale = 1f; textWidth = 0f; + if (!string.IsNullOrEmpty(textToBuild)) { cachedText = gui.textCache.GetCached((fontSize, textToBuild, uint.MaxValue)); textWidth = gui.PixelsToUnits(cachedText.texRect.w); + if (textWidth > realTextRect.Width) { scale = realTextRect.Width / textWidth; } @@ -58,16 +61,20 @@ private void GetTextParameters(string? textToBuild, Rect textRect, FontFile.Font public bool BuildTextInput(string? text, out string newText, string? placeholder, FontFile.FontSize fontSize, bool delayed, TextBoxDisplayStyle displayStyle) { newText = text ?? ""; Rect textRect, realTextRect; + using (gui.EnterGroup(displayStyle.Padding, RectAllocator.LeftRow)) { float lineSize = gui.PixelsToUnits(fontSize.lineSize); + if (displayStyle.Icon != Icon.None) { gui.BuildIcon(displayStyle.Icon, lineSize, (SchemeColor)displayStyle.ColorGroup + 3); } textRect = gui.RemainingRow(0.3f).AllocateRect(0, lineSize, RectAlignment.MiddleFullRow); } + var boundingRect = gui.lastRect; bool focused = rect == boundingRect; + if (focused && this.text == null) { this.text = text ?? ""; SetCaret(0, this.text.Length); @@ -95,6 +102,7 @@ public bool BuildTextInput(string? text, out string newText, string? placeholder case ImGuiAction.Build: SchemeColor textColor = (SchemeColor)displayStyle.ColorGroup + 2; string? textToBuild; + if (focused && !string.IsNullOrEmpty(text)) { textToBuild = this.text; } @@ -107,6 +115,7 @@ public bool BuildTextInput(string? text, out string newText, string? placeholder } GetTextParameters(textToBuild, textRect, fontSize, displayStyle.Alignment, out TextCache? cachedText, out float scale, out float textWidth, out realTextRect); + if (cachedText != null) { gui.DrawRenderable(realTextRect, cachedText, textColor); } @@ -129,23 +138,28 @@ public bool BuildTextInput(string? text, out string newText, string? placeholder } } } + gui.DrawRectangle(boundingRect, (SchemeColor)displayStyle.ColorGroup); + break; } if (boundingRect == prevRect) { bool changed = text != prevText; + if (changed) { newText = prevText; } prevRect = default; prevText = ""; + return changed; } if (focused && !delayed && this.text != text) { newText = this.text; + return true; } @@ -162,6 +176,7 @@ private float GetCharacterPosition(int id, FontFile.FontSize fontSize, float max } _ = SDL_ttf.TTF_SizeUNICODE(fontSize.handle, text[..id], out int w, out _); + return gui.PixelsToUnits(w); } @@ -176,6 +191,7 @@ private void DeleteSelected() { private void SetCaret(int position, int selection = -1) { position = MathUtils.Clamp(position, 0, text.Length); selection = selection < 0 ? position : Math.Min(selection, text.Length); + if (caret != position || selectionAnchor != selection) { caret = position; selectionAnchor = selection; @@ -209,6 +225,7 @@ private void AddEditHistory(EditHistoryEvent evt) { public bool KeyDown(SDL.SDL_Keysym key) { bool ctrl = (key.mod & SDL.SDL_Keymod.KMOD_CTRL) != 0; bool shift = (key.mod & SDL.SDL_Keymod.KMOD_SHIFT) != 0; + switch (key.scancode) { case SDL.SDL_Scancode.SDL_SCANCODE_BACKSPACE: if (selectionAnchor != caret) { @@ -216,10 +233,13 @@ public bool KeyDown(SDL.SDL_Keysym key) { } else if (caret > 0) { int removeFrom = caret; + if (ctrl) { bool stopOnNextNonLetter = false; + while (removeFrom > 0) { removeFrom--; + if (char.IsLetterOrDigit(text[removeFrom])) { stopOnNextNonLetter = true; } @@ -237,6 +257,7 @@ public bool KeyDown(SDL.SDL_Keysym key) { text = text.Remove(removeFrom, caret - removeFrom); SetCaret(removeFrom); } + break; case SDL.SDL_Scancode.SDL_SCANCODE_DELETE: if (selectionAnchor != caret) { @@ -246,13 +267,16 @@ public bool KeyDown(SDL.SDL_Keysym key) { AddEditHistory(EditHistoryEvent.Delete); text = text.Remove(caret, 1); } + break; case SDL.SDL_Scancode.SDL_SCANCODE_RETURN: case SDL.SDL_Scancode.SDL_SCANCODE_RETURN2: case SDL.SDL_Scancode.SDL_SCANCODE_KP_ENTER: case SDL.SDL_Scancode.SDL_SCANCODE_ESCAPE: InputSystem.Instance.SetKeyboardFocus(null); + return false; + case SDL.SDL_Scancode.SDL_SCANCODE_LEFT: if (shift) { SetCaret(caret - 1, selectionAnchor); @@ -301,7 +325,7 @@ public bool KeyDown(SDL.SDL_Keysym key) { } public bool TextInput(string input) { - if (input.IndexOf(' ') >= 0) { + if (input.Contains(' ')) { lastEvent = EditHistoryEvent.None; } @@ -313,6 +337,7 @@ public bool TextInput(string input) { text = text.Insert(caret, input); SetCaret(caret + input.Length); ResetCaret(); + return true; } @@ -329,8 +354,8 @@ public void FocusChanged(bool focused) { } // Fast operations with char* instead of strings - [DllImport("SDL2_ttf.dll", CallingConvention = CallingConvention.Cdecl)] - private static extern unsafe int TTF_SizeUNICODE(IntPtr font, char* text, out int w, out int h); + [LibraryImport("SDL2_ttf.dll"), UnmanagedCallConv(CallConvs = new Type[] { typeof(System.Runtime.CompilerServices.CallConvCdecl) })] + private static unsafe partial int TTF_SizeUNICODE(IntPtr font, char* text, out int w, out int h); private unsafe int FindCaretIndex(string? text, float position, FontFile.FontSize fontSize, float maxWidth) { if (string.IsNullOrEmpty(text) || position <= 0f) { @@ -340,17 +365,20 @@ private unsafe int FindCaretIndex(string? text, float position, FontFile.FontSiz var cachedText = gui.textCache.GetCached((fontSize, text, uint.MaxValue)); float maxW = gui.PixelsToUnits(cachedText.texRect.w); float scale = 1f; + if (maxW > maxWidth) { scale = maxWidth / maxW; maxW = maxWidth; } int min = 0, max = text.Length; float minW = 0f; + if (position >= maxW) { return max; } nint handle = fontSize.handle; + fixed (char* arr = text) { while (max > min + 1) { float ratio = (maxW - position) / (maxW - minW); @@ -360,6 +388,7 @@ private unsafe int FindCaretIndex(string? text, float position, FontFile.FontSiz _ = TTF_SizeUNICODE(handle, arr, out int w, out _); arr[mid] = prev; float midW = gui.PixelsToUnits(w) * scale; + if (midW > position) { max = mid; maxW = midW; diff --git a/Yafc.UI/ImGui/ImGuiUtils.cs b/Yafc.UI/ImGui/ImGuiUtils.cs index 73841cdf..3af151aa 100644 --- a/Yafc.UI/ImGui/ImGuiUtils.cs +++ b/Yafc.UI/ImGui/ImGuiUtils.cs @@ -5,6 +5,7 @@ using SDL2; namespace Yafc.UI; + // ButtonEvent implicitly converts to true if it is a click event, so for simple buttons that only handle clicks you can just use if() public readonly struct ButtonEvent { private readonly int value; @@ -117,22 +118,27 @@ public static ButtonEvent BuildButton(this ImGui gui, string text, SchemeColor c public static ButtonEvent BuildContextMenuButton(this ImGui gui, string text, string? rightText = null, Icon icon = default, bool disabled = false) { gui.allocator = RectAllocator.Stretch; + using (gui.EnterGroup(DefaultButtonPadding, RectAllocator.LeftRow, SchemeColor.BackgroundText)) { var textColor = disabled ? gui.textColor + 1 : gui.textColor; + if (icon != default) { gui.BuildIcon(icon, color: icon >= Icon.FirstCustom ? disabled ? SchemeColor.SourceFaint : SchemeColor.Source : textColor); } gui.BuildText(text, TextBlockDisplayStyle.WrappedText with { Color = textColor }); + if (rightText != null) { gui.allocator = RectAllocator.RightRow; gui.BuildText(rightText, new TextBlockDisplayStyle(Alignment: RectAlignment.MiddleRight)); } } + return gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Grey); } - public static void CaptureException(this Task task) => _ = task.ContinueWith(t => throw t.Exception!, TaskContinuationOptions.OnlyOnFaulted); // null-forgiving: OnlyOnFaulted guarantees that Exception is non-null. + // null-forgiving: OnlyOnFaulted guarantees that Exception is non-null. + public static void CaptureException(this Task task) => _ = task.ContinueWith(t => throw t.Exception!, TaskContinuationOptions.OnlyOnFaulted); public static bool BuildMouseOverIcon(this ImGui gui, Icon icon, SchemeColor color = SchemeColor.BackgroundText) { if (gui.isBuilding && gui.IsMouseOver(gui.lastRect)) { @@ -145,11 +151,13 @@ public static bool BuildMouseOverIcon(this ImGui gui, Icon icon, SchemeColor col public static ButtonEvent BuildRedButton(this ImGui gui, string text) { Rect textRect; TextCache? cache; + using (gui.EnterGroup(DefaultButtonPadding)) { textRect = gui.AllocateTextRect(out cache, text, TextBlockDisplayStyle.Centered); } var evt = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Error); + if (gui.isBuilding) { gui.DrawRenderable(textRect, cache, gui.IsMouseOver(gui.lastRect) ? SchemeColor.ErrorText : SchemeColor.Error); } @@ -159,11 +167,13 @@ public static ButtonEvent BuildRedButton(this ImGui gui, string text) { public static ButtonEvent BuildRedButton(this ImGui gui, Icon icon, float size = 1.5f, bool invertedColors = false) { Rect iconRect; + using (gui.EnterGroup(new Padding(0.3f))) { iconRect = gui.AllocateRect(size, size, RectAlignment.Middle); } var evt = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Error); + if (gui.isBuilding) { SchemeColor color = invertedColors ? SchemeColor.ErrorText : SchemeColor.Error; @@ -177,7 +187,9 @@ public static ButtonEvent BuildRedButton(this ImGui gui, Icon icon, float size = return evt; } - public static ButtonEvent BuildButton(this ImGui gui, Icon icon, SchemeColor normal = SchemeColor.None, SchemeColor over = SchemeColor.Grey, SchemeColor down = SchemeColor.None, float size = 1.5f) { + public static ButtonEvent BuildButton(this ImGui gui, Icon icon, SchemeColor normal = SchemeColor.None, + SchemeColor over = SchemeColor.Grey, SchemeColor down = SchemeColor.None, float size = 1.5f) { + using (gui.EnterGroup(new Padding(0.3f))) { gui.BuildIcon(icon, size); } @@ -185,11 +197,14 @@ public static ButtonEvent BuildButton(this ImGui gui, Icon icon, SchemeColor nor return gui.BuildButton(gui.lastRect, normal, over, down); } - public static ButtonEvent BuildButton(this ImGui gui, Icon icon, string text, SchemeColor normal = SchemeColor.None, SchemeColor over = SchemeColor.Grey, SchemeColor down = SchemeColor.None, float size = 1.5f) { + public static ButtonEvent BuildButton(this ImGui gui, Icon icon, string text, SchemeColor normal = SchemeColor.None, + SchemeColor over = SchemeColor.Grey, SchemeColor down = SchemeColor.None, float size = 1.5f) { + using (gui.EnterGroup(new Padding(0.3f), RectAllocator.LeftRow)) { gui.BuildIcon(icon, size); gui.BuildText(text); } + return gui.BuildButton(gui.lastRect, normal, over, down); } @@ -213,6 +228,7 @@ public static bool BuildCheckBox(this ImGui gui, string text, bool value, out bo } newValue = value; + return false; } @@ -220,16 +236,22 @@ public static ButtonEvent BuildRadioButton(this ImGui gui, string option, bool s if (textColor == SchemeColor.None) { textColor = enabled ? SchemeColor.PrimaryText : SchemeColor.PrimaryTextFaint; } + using (gui.EnterRow()) { gui.BuildIcon(selected ? Icon.RadioCheck : Icon.RadioEmpty, 1.5f, textColor); gui.BuildText(option, TextBlockDisplayStyle.WrappedText with { Color = textColor }); } + if (!enabled) { return ButtonEvent.None; } ButtonEvent click = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.None); - if (click == ButtonEvent.Click && selected) { return ButtonEvent.None; } + + if (click == ButtonEvent.Click && selected) { + return ButtonEvent.None; + } + return click; } @@ -240,10 +262,12 @@ public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList options public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList<(string option, string? tooltip)> options, int selected, out int newSelected, SchemeColor textColor = SchemeColor.None, bool enabled = true) { newSelected = selected; + for (int i = 0; i < options.Count; i++) { ButtonEvent evt = BuildRadioButton(gui, options[i].option, selected == i, textColor, enabled); + if (!string.IsNullOrEmpty(options[i].tooltip)) { - evt.WithTooltip(gui, options[i].tooltip!); + _ = evt.WithTooltip(gui, options[i].tooltip!); } if (evt) { newSelected = i; @@ -255,6 +279,7 @@ public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList<(string option, public static bool BuildErrorRow(this ImGui gui, string text) { bool closed = false; + using (gui.EnterRow(allocator: RectAllocator.RightRow, textColor: SchemeColor.ErrorText)) { if (gui.BuildButton(Icon.Close, size: 1f, over: SchemeColor.ErrorAlt)) { closed = true; @@ -262,6 +287,7 @@ public static bool BuildErrorRow(this ImGui gui, string text) { gui.RemainingRow().BuildText(text, TextBlockDisplayStyle.Centered); } + if (gui.isBuilding) { gui.DrawRectangle(gui.lastRect, SchemeColor.Error); } @@ -275,6 +301,7 @@ public static bool BuildIntegerInput(this ImGui gui, int value, out int newValue } newValue = value; + return false; } @@ -315,17 +342,18 @@ public void Next() { savedContext = default; currentRowIndex = -1; } + currentRowIndex++; + if (currentRowIndex == 0) { savedContext = gui.EnterRow(0f); gui.spacing = 0f; } + savedContext.SetManualRect(new Rect((elementWidth + spacing) * currentRowIndex, 0f, elementWidth, 0f), RectAllocator.Stretch); } - public bool isEmpty() => gui == null; - - public void Dispose() => savedContext.Dispose(); + public readonly void Dispose() => savedContext.Dispose(); } public static InlineGridBuilder EnterInlineGrid(this ImGui gui, float elementWidth, float spacing = 0f, int maxElemCount = 0) => new InlineGridBuilder( @@ -343,6 +371,7 @@ public static bool InitiateDrag(this ImGui gui, Rect moveHandle, Rect content gui.SetDraggingArea(contents, index, backgroundColor); return true; } + return false; } @@ -384,11 +413,13 @@ public static bool BuildSlider(this ImGui gui, float value, out float newValue, float positionX = (gui.mousePosition.X - sliderRect.X - 0.5f) / (sliderRect.Width - 1f); newValue = MathUtils.Clamp(positionX, 0f, 1f); gui.Rebuild(); + return true; } public static bool BuildSearchBox(this ImGui gui, SearchQuery searchQuery, out SearchQuery newQuery, string placeholder = "Search", bool setInitialFocus = false) { newQuery = searchQuery; + if (gui.BuildTextInput(searchQuery.query, out string newText, placeholder, Icon.Search, setInitialFocus: setInitialFocus)) { newQuery = new SearchQuery(newText); return true; @@ -426,6 +457,7 @@ public RowWithHelpIcon(ImGui gui, string tooltip, bool rightJustify) { this.gui = gui; this.tooltip = tooltip; row = gui.EnterRow(); // using (gui.EnterRow()) { + if (rightJustify) { gui.allocator = RectAllocator.RightRow; helpCenterX = gui.AllocateRect(1, 1).Center.X; @@ -436,6 +468,7 @@ public RowWithHelpIcon(ImGui gui, string tooltip, bool rightJustify) { public void Dispose() { Rect rect; + if (helpCenterX != 0) { // if (rightJustify) group.Dispose(); // end using block for EnterGroup rect = Rect.Square(helpCenterX, gui.lastRect.Center.Y, 1.25f); @@ -444,8 +477,9 @@ public void Dispose() { rect = gui.AllocateRect(1.25f, 1.25f); // Despite requesting 1.25 x 1.25, rect will be 1.25 x RowHeight, which might be greater than 1.25. rect = Rect.Square(rect.Center, 1.25f); // Get a vertically-centered rect that's actually 1.25 x 1.25. } + gui.DrawIcon(rect, Icon.Help, SchemeColor.BackgroundText); - gui.BuildButton(rect, SchemeColor.None, SchemeColor.Grey).WithTooltip(gui, tooltip, rect); + _ = gui.BuildButton(rect, SchemeColor.None, SchemeColor.Grey).WithTooltip(gui, tooltip, rect); row.Dispose(); // end using block for EnterRow } } diff --git a/Yafc.UI/ImGui/ScrollArea.cs b/Yafc.UI/ImGui/ScrollArea.cs index 5fe560f8..b483f099 100644 --- a/Yafc.UI/ImGui/ScrollArea.cs +++ b/Yafc.UI/ImGui/ScrollArea.cs @@ -4,6 +4,7 @@ using SDL2; namespace Yafc.UI; + /// Provide scrolling support for any component. /// The component should use the property to get the offset of rendering the contents. public abstract class Scrollable(bool vertical, bool horizontal, bool collapsible) : IKeyboardFocus { @@ -28,6 +29,7 @@ public void Build(ImGui gui, float availableHeight, bool useBottomPadding = fals this.gui = gui; var rect = gui.statePosition; float width = rect.Width; + if (vertical) { width -= ScrollbarSize; } @@ -35,6 +37,7 @@ public void Build(ImGui gui, float availableHeight, bool useBottomPadding = fals if (gui.isBuilding) { // Calculate required size, including padding if needed requiredContentSize = MeasureContent(width, gui); + if (requiredContentSize.Y > availableHeight && useBottomPadding) { requiredContentSize.Y += BottomPaddingInPixels / gui.pixelsPerUnit; } @@ -50,6 +53,7 @@ public void Build(ImGui gui, float availableHeight, bool useBottomPadding = fals scroll = Vector2.Clamp(scroll, Vector2.Zero, maxScroll); contentRect.Height = realHeight; + if (horizontal && maxScroll.X > 0) { contentRect.Height -= ScrollbarSize; } @@ -124,6 +128,7 @@ public virtual Vector2 scroll { get => _scroll; set { value = Vector2.Clamp(value, Vector2.Zero, maxScroll); + if (value != _scroll) { _scroll = value; gui?.Rebuild(); @@ -150,6 +155,7 @@ public float scrollX { public bool KeyDown(SDL.SDL_Keysym key) { bool ctrl = InputSystem.Instance.control; bool shift = InputSystem.Instance.shift; + switch ((ctrl, shift, key.scancode)) { case (false, false, SDL.SDL_Scancode.SDL_SCANCODE_UP): scrollY -= 3; @@ -275,6 +281,7 @@ public override Vector2 scroll { set { base.scroll = value; int row = CalcFirstBlock(); + if (row != firstVisibleBlock) { RebuildContents(); } @@ -283,6 +290,7 @@ public override Vector2 scroll { protected override void BuildContents(ImGui gui) { elementsPerRow = MathUtils.Floor((gui.width + _spacing) / (elementSize.X + _spacing)); + if (elementsPerRow < 1) { elementsPerRow = 1; } @@ -292,10 +300,12 @@ protected override void BuildContents(ImGui gui) { // Scroll up until there are maxRowsVisible, or to the top. int firstRow = Math.Max(0, Math.Min(firstVisibleBlock * bufferRows, rowCount - maxRowsVisible)); int index = firstRow * elementsPerRow; + if (index >= _data.Count) { // If _data is empty, there's nothing to draw. Make sure MeasureContent reports that, instead of the size of the most recent non-empty content. // This will remove the scroll bar when the search doesn't match anything. gui.lastContentRect = new Rect(gui.lastContentRect.X, gui.lastContentRect.Y, 0, 0); + return; } @@ -304,17 +314,21 @@ protected override void BuildContents(ImGui gui) { var offset = gui.statePosition.Position; float elementWidth = gui.width / elementsPerRow; Rect cell = new Rect(offset.X, offset.Y, elementWidth - _spacing, elementSize.Y); + for (int row = firstRow; row < lastRow; row++) { cell.Y = row * (elementSize.Y + _spacing); + for (int elem = 0; elem < elementsPerRow; elem++) { cell.X = elem * elementWidth; manualPlacing.SetManualRectRaw(cell); BuildElement(gui, _data[index], index); + if (reorder != null) { if (gui.DoListReordering(cell, cell, index, out int fromIndex)) { reorder(fromIndex, index); } } + if (++index >= _data.Count) { return; } diff --git a/Yafc.UI/Rendering/Font.cs b/Yafc.UI/Rendering/Font.cs index 4b36402d..47730f12 100644 --- a/Yafc.UI/Rendering/Font.cs +++ b/Yafc.UI/Rendering/Font.cs @@ -3,21 +3,21 @@ using SDL2; namespace Yafc.UI; -public class Font { + +public class Font(FontFile file, float size) { public static Font header { get; set; } = null!; // null-forgiving: Set by Main public static Font subheader { get; set; } = null!; // null-forgiving: Set by Main public static Font productionTableHeader { get; set; } = null!; // null-forgiving: Set by Main public static Font text { get; set; } = null!; // null-forgiving: Set by Main - public readonly float size; - - private readonly FontFile fontFile; + public readonly float size = size; private FontFile.FontSize? lastFontSize; public FontFile.FontSize GetFontSize(float pixelsPreUnit) { int actualSize = MathUtils.Round(pixelsPreUnit * size); + if (lastFontSize == null || lastFontSize.size != actualSize) { - lastFontSize = fontFile.GetFontForSize(actualSize); + lastFontSize = file.GetFontForSize(actualSize); } return lastFontSize; @@ -27,18 +27,12 @@ public FontFile.FontSize GetFontSize(float pixelsPreUnit) { public float GetLineSize(float pixelsPreUnit) => GetFontSize(pixelsPreUnit).lineSize / pixelsPreUnit; - public Font(FontFile file, float size) { - this.size = size; - fontFile = file; - } - - public void Dispose() => fontFile.Dispose(); + public void Dispose() => file.Dispose(); } -public class FontFile : IDisposable { - public readonly string fileName; +public class FontFile(string fileName) : IDisposable { + public readonly string fileName = fileName; private readonly Dictionary sizes = []; - public FontFile(string fileName) => this.fileName = fileName; public class FontSize : UnmanagedResource { public readonly int size; diff --git a/Yafc.UI/Rendering/IconAtlas.cs b/Yafc.UI/Rendering/IconAtlas.cs index 52e6eba2..b3dbc78b 100644 --- a/Yafc.UI/Rendering/IconAtlas.cs +++ b/Yafc.UI/Rendering/IconAtlas.cs @@ -3,6 +3,7 @@ using Serilog; namespace Yafc.UI; + public class IconAtlas { private static readonly ILogger logger = Logging.GetLogger(); private IntPtr prevRender; @@ -13,16 +14,10 @@ public class IconAtlas { private const int IconsPerRow = TextureSize / IconStride; private const int IconPerTexture = IconsPerRow * IconsPerRow; - private struct TextureInfo { - public TextureInfo(IntPtr texture) { - this.texture = texture; - existMap = new bool[IconPerTexture]; - color = RenderingUtils.White; - } - - public readonly IntPtr texture; - public readonly bool[] existMap; - public SDL.SDL_Color color; + private struct TextureInfo(IntPtr texture) { + public readonly IntPtr texture = texture; + public readonly bool[] existMap = new bool[IconPerTexture]; + public SDL.SDL_Color color = RenderingUtils.White; } private TextureInfo[] textures = new TextureInfo[1]; @@ -31,14 +26,17 @@ public void DrawIcon(IntPtr renderer, Icon icon, SDL.SDL_Rect position, SDL.SDL_ if (renderer != prevRender) { Array.Clear(textures, 0, textures.Length); } + prevRender = renderer; int index = (int)icon; ref var texture = ref textures[0]; int ix = index % IconsPerRow; int iy = index / IconsPerRow; + if (index >= IconPerTexture) // That is very unlikely { int texId = index / IconPerTexture; + if (texId >= textures.Length) { Array.Resize(ref textures, texId + 1); } @@ -47,17 +45,23 @@ public void DrawIcon(IntPtr renderer, Icon icon, SDL.SDL_Rect position, SDL.SDL_ iy -= texId * IconsPerRow; texture = ref textures[texId]; } + SDL.SDL_Rect rect = new SDL.SDL_Rect { x = ix * IconStride, y = iy * IconStride, w = IconSize, h = IconSize }; + if (texture.texture == IntPtr.Zero) { texture = new TextureInfo(SDL.SDL_CreateTexture(renderer, SDL.SDL_PIXELFORMAT_RGBA8888, (int)SDL.SDL_TextureAccess.SDL_TEXTUREACCESS_STATIC, TextureSize, TextureSize)); _ = SDL.SDL_SetTextureBlendMode(texture.texture, SDL.SDL_BlendMode.SDL_BLENDMODE_BLEND); } + if (!texture.existMap[index]) { nint iconSurfacePtr = IconCollection.GetIconSurface(icon); + if (iconSurfacePtr == IntPtr.Zero) { logger.Error("Non-existing icon: " + icon); + return; } + ref var iconSurface = ref RenderingUtils.AsSdlSurface(iconSurfacePtr); texture.existMap[index] = true; _ = SDL.SDL_UpdateTexture(texture.texture, ref rect, iconSurface.pixels, iconSurface.pitch); diff --git a/Yafc.UI/Rendering/RenderingUtils.cs b/Yafc.UI/Rendering/RenderingUtils.cs index 3325ee8c..9104d29f 100644 --- a/Yafc.UI/Rendering/RenderingUtils.cs +++ b/Yafc.UI/Rendering/RenderingUtils.cs @@ -4,6 +4,7 @@ using SDL2; namespace Yafc.UI; + public static class RenderingUtils { public static readonly IntPtr cursorCaret = SDL.SDL_CreateSystemCursor(SDL.SDL_SystemCursor.SDL_SYSTEM_CURSOR_IBEAM); public static readonly IntPtr cursorArrow = SDL.SDL_CreateSystemCursor(SDL.SDL_SystemCursor.SDL_SYSTEM_CURSOR_ARROW); @@ -20,8 +21,10 @@ public static class RenderingUtils { public static SchemeColor GetTextColorFromBackgroundColor(SchemeColor color) => (SchemeColor)((int)color & ~3) + 2; - private static readonly SDL.SDL_Color[] LightModeScheme = { - default, new SDL.SDL_Color {b = 255, g = 128, a = 60}, ColorFromHex(0x0645AD), ColorFromHex(0x1b5e20), // Special group +#pragma warning disable IDE0055 // IDE0055: Fix formatting. No need - the values are grouped for better readability + + private static readonly SDL.SDL_Color[] LightModeScheme = [ + default, new SDL.SDL_Color { b = 255, g = 128, a = 60 }, ColorFromHex(0x0645AD), ColorFromHex(0x1b5e20), // Special group White, Black, White, WhiteTransparent, // pure group ColorFromHex(0xf4f4f4), White, Black, BlackTransparent, // Background group @@ -37,10 +40,10 @@ public static class RenderingUtils { ColorFromHex(0xffffe8), ColorFromHex(0xffffef), ColorFromHex(0x8c8756), ColorFromHex(0x0), // Yellow ColorFromHex(0xffe8e8), ColorFromHex(0xffefef), ColorFromHex(0xaa5555), ColorFromHex(0x0), // Red ColorFromHex(0xe8efff), ColorFromHex(0xeff4ff), ColorFromHex(0x526ea5), ColorFromHex(0x0), // Blue - }; + ]; - private static readonly SDL.SDL_Color[] DarkModeScheme = { - default, new SDL.SDL_Color {b = 255, g = 128, a = 120}, ColorFromHex(0xff9800), ColorFromHex(0x1b5e20), // Special group + private static readonly SDL.SDL_Color[] DarkModeScheme = [ + default, new SDL.SDL_Color { b = 255, g = 128, a = 120 }, ColorFromHex(0xff9800), ColorFromHex(0x1b5e20), // Special group Black, White, White, WhiteTransparent, // pure group ColorFromHex(0x141414), Black, White, WhiteTransparent, // Background group @@ -56,7 +59,9 @@ public static class RenderingUtils { ColorFromHex(0x28260b), ColorFromHex(0x191807), ColorFromHex(0x5b582a), ColorFromHex(0x0), // Yellow ColorFromHex(0x270c0c), ColorFromHex(0x190808), ColorFromHex(0x922626), ColorFromHex(0x0), // Red ColorFromHex(0x0c0c27), ColorFromHex(0x080819), ColorFromHex(0x2626ab), ColorFromHex(0x0) // Blue - }; + ]; + +#pragma warning restore IDE0055 private static SDL.SDL_Color[] SchemeColors = LightModeScheme; @@ -83,6 +88,7 @@ static unsafe RenderingUtils() { const float center = (circleSize - 1) / 2f; uint* pixels = (uint*)surface.pixels; + for (int x = 0; x < 32; x++) { for (int y = 0; y < 32; y++) { float dx = (center - x) / center; @@ -107,14 +113,9 @@ static unsafe RenderingUtils() { _ = SDL.SDL_SetSurfaceColorMod(CircleSurface, 0, 0, 0); } - public struct BlitMapping { - public SDL.SDL_Rect position; - public SDL.SDL_Rect texture; - - public BlitMapping(SDL.SDL_Rect texture, SDL.SDL_Rect position) { - this.texture = texture; - this.position = position; - } + public struct BlitMapping(SDL.SDL_Rect texture, SDL.SDL_Rect position) { + public SDL.SDL_Rect position = position; + public SDL.SDL_Rect texture = texture; } public static void GetBorderParameters(float unitsToPixels, RectangleBorder border, out int top, out int side, out int bottom) { diff --git a/Yafc.UI/Rendering/UnmanagedResource.cs b/Yafc.UI/Rendering/UnmanagedResource.cs index 4cfdae08..73b7ff91 100644 --- a/Yafc.UI/Rendering/UnmanagedResource.cs +++ b/Yafc.UI/Rendering/UnmanagedResource.cs @@ -1,6 +1,7 @@ using System; namespace Yafc.UI; + public abstract class UnmanagedResource : IDisposable { protected IntPtr _handle; diff --git a/Yafc/Program.cs b/Yafc/Program.cs index 73d5f6d7..4f9e035d 100644 --- a/Yafc/Program.cs +++ b/Yafc/Program.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + public static class Program { public static bool hasOverriddenFont; diff --git a/Yafc/Utils/CommandLineParser.cs b/Yafc/Utils/CommandLineParser.cs index bbb0a568..8a738cc1 100644 --- a/Yafc/Utils/CommandLineParser.cs +++ b/Yafc/Utils/CommandLineParser.cs @@ -3,6 +3,7 @@ using System.Linq; namespace Yafc; + /// /// The role of this class is to handle things related to launching Yafc from the command line.
/// That includes handling Windows commands after associating the Yafc executable with .yafc files,
diff --git a/Yafc/Utils/ObjectDisplayStyles.cs b/Yafc/Utils/ObjectDisplayStyles.cs index 472d22bb..57995e6f 100644 --- a/Yafc/Utils/ObjectDisplayStyles.cs +++ b/Yafc/Utils/ObjectDisplayStyles.cs @@ -22,7 +22,8 @@ public record IconDisplayStyle(float Size, MilestoneDisplay MilestoneDisplay, bo /// The background color to display behind the icon. public record ButtonDisplayStyle(float Size, MilestoneDisplay MilestoneDisplay, SchemeColor BackgroundColor) : IconDisplayStyle(Size, MilestoneDisplay, true) { /// - /// Creates a new for buttons that do not have a background color. These buttons will not obey the setting. + /// Creates a new for buttons that do not have a background color. + /// These buttons will not obey the setting. /// /// The icon size. The production tables use size 3. /// The option to use when drawing the icon. diff --git a/Yafc/Utils/Preferences.cs b/Yafc/Utils/Preferences.cs index e4228cbb..f2a5218a 100644 --- a/Yafc/Utils/Preferences.cs +++ b/Yafc/Utils/Preferences.cs @@ -6,6 +6,7 @@ using Yafc.Model; namespace Yafc; + public class Preferences { public static readonly Preferences Instance; public static readonly string appDataFolder; diff --git a/Yafc/Utils/WindowsClipboard.cs b/Yafc/Utils/WindowsClipboard.cs index 0068c457..c4c0fdfe 100644 --- a/Yafc/Utils/WindowsClipboard.cs +++ b/Yafc/Utils/WindowsClipboard.cs @@ -4,11 +4,19 @@ using Yafc.UI; namespace Yafc; -public static class WindowsClipboard { - [DllImport("user32.dll")] private static extern bool OpenClipboard(IntPtr handle); - [DllImport("user32.dll")] private static extern bool EmptyClipboard(); - [DllImport("user32.dll")] private static extern IntPtr SetClipboardData(uint format, IntPtr data); - [DllImport("user32.dll")] private static extern bool CloseClipboard(); + +public static partial class WindowsClipboard { + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool OpenClipboard(IntPtr handle); + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool EmptyClipboard(); + [LibraryImport("user32.dll")] + private static partial IntPtr SetClipboardData(uint format, IntPtr data); + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool CloseClipboard(); private static unsafe void CopyToClipboard(uint format, in T header, Span data) where T : unmanaged { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/Yafc/Widgets/DataGrid.cs b/Yafc/Widgets/DataGrid.cs index a4a5f783..3ccde1ea 100644 --- a/Yafc/Widgets/DataGrid.cs +++ b/Yafc/Widgets/DataGrid.cs @@ -5,6 +5,7 @@ using SDL2; namespace Yafc.UI; + public abstract class DataColumn { public readonly float minWidth; public readonly float maxWidth; @@ -42,7 +43,7 @@ public DataColumn(float initialWidth, float minWidth = 0f, float maxWidth = 0f, } catch (ArgumentException) { // Not including the CreateDelegate's exception, because YAFC displays only the innermost exception message. - throw new ArgumentException($"'{storage}' is not a instance property of type {typeof(float).Name} in {nameof(Preferences)}."); + throw new ArgumentException($"'{storage}' is not a instance property of type {nameof(Single)} in {nameof(Preferences)}."); } } } @@ -92,7 +93,6 @@ public DataGrid(params DataColumn[] columns) { spacing = innerPadding.left + innerPadding.right; } - private void BuildHeaderResizer(ImGui gui, DataColumn column, Rect rect) { switch (gui.action) { case ImGuiAction.Build: diff --git a/Yafc/Widgets/ImmediateWidgets.cs b/Yafc/Widgets/ImmediateWidgets.cs index f19df995..b19876b1 100644 --- a/Yafc/Widgets/ImmediateWidgets.cs +++ b/Yafc/Widgets/ImmediateWidgets.cs @@ -99,18 +99,24 @@ public static bool BuildFloatInput(this ImGui gui, DisplayAmount amount, TextBox return false; } - public static Click BuildFactorioObjectButtonBackground(this ImGui gui, Rect rect, FactorioObject? obj, SchemeColor bgColor = SchemeColor.None, ObjectTooltipOptions tooltipOptions = default) { + public static Click BuildFactorioObjectButtonBackground(this ImGui gui, Rect rect, FactorioObject? obj, SchemeColor bgColor = SchemeColor.None, + ObjectTooltipOptions tooltipOptions = default) { + SchemeColor overColor; + if (bgColor == SchemeColor.None) { overColor = SchemeColor.Grey; } else { overColor = bgColor + 1; } + if (MainScreen.Instance.IsSameObjectHovered(gui, obj)) { bgColor = overColor; } + var evt = gui.BuildButton(rect, bgColor, overColor, button: 0); + if (evt == ButtonEvent.MouseOver && obj != null) { MainScreen.Instance.ShowTooltip(obj, gui, rect, tooltipOptions); } @@ -172,15 +178,19 @@ public static bool BuildInlineObjectList(this ImGui gui, IEnumerable list, Predicate? checkMark = null, Func? extra = null) where T : FactorioObject { gui.BuildText(header, Font.productionTableHeader); IEnumerable sortedList; + if (ordering == DataUtils.AlreadySortedRecipe) { sortedList = list.AsEnumerable(); } else { sortedList = list.OrderBy(e => e, ordering ?? DataUtils.DefaultOrdering); } + selected = null; + foreach (var elem in sortedList.Take(maxCount)) { string? extraText = extra?.Invoke(elem); + if (gui.BuildFactorioObjectButtonWithText(elem, extraText) == Click.Left) { selected = elem; } @@ -193,7 +203,9 @@ public static bool BuildInlineObjectList(this ImGui gui, IEnumerable list, return selected != null; } - public static void BuildInlineObjectListAndButton(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, int count = 6, bool multiple = false, Predicate? checkMark = null, Func? extra = null) where T : FactorioObject { + public static void BuildInlineObjectListAndButton(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, + int count = 6, bool multiple = false, Predicate? checkMark = null, Func? extra = null) where T : FactorioObject { + using (gui.EnterGroup(default, RectAllocator.Stretch)) { if (gui.BuildInlineObjectList(list, ordering, header, out var selected, count, checkMark, extra)) { selectItem(selected); @@ -213,7 +225,9 @@ public static void BuildInlineObjectListAndButton(this ImGui gui, ICollection } } - public static void BuildInlineObjectListAndButtonWithNone(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, int count = 6, Func? extra = null) where T : FactorioObject { + public static void BuildInlineObjectListAndButtonWithNone(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, + int count = 6, Func? extra = null) where T : FactorioObject { + using (gui.EnterGroup(default, RectAllocator.Stretch)) { if (gui.BuildInlineObjectList(list, ordering, header, out var selected, count, null, extra)) { selectItem(selected); @@ -240,12 +254,14 @@ public static Click BuildFactorioObjectWithAmount(this ImGui gui, FactorioObject gui.allocator = RectAllocator.Stretch; gui.spacing = 0f; Click clicked = gui.BuildFactorioObjectButton(goods, buttonDisplayStyle, tooltipOptions); + if (goods != null) { gui.BuildText(DataUtils.FormatAmount(amount.Value, amount.Unit), textDisplayStyle); if (InputSystem.Instance.control && gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Grey) == ButtonEvent.MouseOver) { ShowPrecisionValueTooltip(gui, amount, goods); } } + return clicked; } } @@ -260,6 +276,7 @@ public static void ShowPrecisionValueTooltip(ImGui gui, DisplayAmount amount, Fa string perMinute = DataUtils.FormatAmountRaw(amount.Value, 60f, "/m", DataUtils.PreciseFormat); string perHour = DataUtils.FormatAmountRaw(amount.Value, 3600f, "/h", DataUtils.PreciseFormat); text = perSecond + "\n" + perMinute + "\n" + perHour; + if (goods is Item item) { text += DataUtils.FormatAmount(MathF.Abs(item.stackSize / amount.Value), UnitOfMeasure.Second, "\n", " per stack"); } @@ -278,13 +295,17 @@ public static void ShowPrecisionValueTooltip(ImGui gui, DisplayAmount amount, Fa /// Shows a dropdown containing the (partial) of elements, with an action for when an element is selected. /// Maximum number of elements in the list. If there are more another popup can be opened by the user to show the full list. /// Width of the popup. Make sure the header text fits! - public static void BuildObjectSelectDropDown(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, float width = 20f, int count = 6, bool multiple = false, Predicate? checkMark = null, Func? extra = null) where T : FactorioObject + public static void BuildObjectSelectDropDown(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, float width = 20f, + int count = 6, bool multiple = false, Predicate? checkMark = null, Func? extra = null) where T : FactorioObject + => gui.ShowDropDown(imGui => imGui.BuildInlineObjectListAndButton(list, ordering, selectItem, header, count, multiple, checkMark, extra), width); /// Shows a dropdown containing the (partial) of elements, with an action for when an element is selected. An additional "Clear" or "None" option will also be displayed. /// Maximum number of elements in the list. If there are more another popup can be opened by the user to show the full list. /// Width of the popup. Make sure the header text fits! - public static void BuildObjectSelectDropDownWithNone(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, float width = 20f, int count = 6, Func? extra = null) where T : FactorioObject + public static void BuildObjectSelectDropDownWithNone(this ImGui gui, ICollection list, IComparer ordering, Action selectItem, string header, float width = 20f, + int count = 6, Func? extra = null) where T : FactorioObject + => gui.ShowDropDown(imGui => imGui.BuildInlineObjectListAndButtonWithNone(list, ordering, selectItem, header, count, extra), width); /// Draws a button displaying the icon belonging to a , or an empty box as a placeholder if no object is available. @@ -294,7 +315,9 @@ public static void BuildObjectSelectDropDownWithNone(this ImGui gui, ICollect /// Display this value and unit. If the user edits the value, the new value will be stored in before returning. /// If , the default, the user can adjust the value by using the scroll wheel while hovering over the editable text. /// If , the scroll wheel will be ignored when hovering. - public static GoodsWithAmountEvent BuildFactorioObjectWithEditableAmount(this ImGui gui, FactorioObject? obj, DisplayAmount amount, ButtonDisplayStyle buttonDisplayStyle, bool allowScroll = true, ObjectTooltipOptions tooltipOptions = default) { + public static GoodsWithAmountEvent BuildFactorioObjectWithEditableAmount(this ImGui gui, FactorioObject? obj, DisplayAmount amount, ButtonDisplayStyle buttonDisplayStyle, + bool allowScroll = true, ObjectTooltipOptions tooltipOptions = default) { + using var group = gui.EnterGroup(default, RectAllocator.Stretch, spacing: 0f); group.SetWidth(3f); GoodsWithAmountEvent evt = (GoodsWithAmountEvent)gui.BuildFactorioObjectButton(obj, buttonDisplayStyle, tooltipOptions); diff --git a/Yafc/Widgets/MainScreenTabBar.cs b/Yafc/Widgets/MainScreenTabBar.cs index b25dcf68..4e5559ae 100644 --- a/Yafc/Widgets/MainScreenTabBar.cs +++ b/Yafc/Widgets/MainScreenTabBar.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public class MainScreenTabBar { private readonly MainScreen screen; private readonly ImGui tabs; @@ -66,7 +67,9 @@ private void BuildContents(ImGui gui) { gui.DrawRectangle(new Rect(gui.lastRect.X, gui.lastRect.Bottom - 0.4f, gui.lastRect.Width, 0.4f), isActive ? SchemeColor.Primary : SchemeColor.Secondary); } - var evt = gui.BuildButton(gui.lastRect, isActive ? SchemeColor.Background : SchemeColor.BackgroundAlt, (isActive || isSecondary) ? SchemeColor.Background : SchemeColor.Grey, button: 0); + var evt = gui.BuildButton(gui.lastRect, isActive ? SchemeColor.Background : SchemeColor.BackgroundAlt, + (isActive || isSecondary) ? SchemeColor.Background : SchemeColor.Grey, button: 0); + if (evt == ButtonEvent.Click) { if (gui.actionParameter == SDL.SDL_BUTTON_RIGHT) { gui.window?.HideTooltip(); // otherwise it's displayed over the dropdown @@ -103,26 +106,26 @@ private void BuildContents(ImGui gui) { } private void PageRightClickDropdown(ImGui gui, ProjectPage page) { - var isSecondary = screen.secondaryPage == page; - var isActive = screen.activePage == page; + bool isSecondary = screen.secondaryPage == page; + bool isActive = screen.activePage == page; if (gui.BuildContextMenuButton("Edit properties")) { - gui.CloseDropdown(); + _ = gui.CloseDropdown(); ProjectPageSettingsPanel.Show(page); } if (!isSecondary && !isActive) { if (gui.BuildContextMenuButton("Open as secondary", "Ctrl+Click")) { - gui.CloseDropdown(); + _ = gui.CloseDropdown(); screen.SetSecondaryPage(page); } } else if (isSecondary) { if (gui.BuildContextMenuButton("Close secondary", "Ctrl+Click")) { - gui.CloseDropdown(); + _ = gui.CloseDropdown(); screen.SetSecondaryPage(null); } } if (gui.BuildContextMenuButton("Duplicate")) { - gui.CloseDropdown(); + _ = gui.CloseDropdown(); if (ProjectPageSettingsPanel.ClonePage(page) is { } copy) { screen.project.RecordUndo().pages.Add(copy); MainScreen.Instance.SetActivePage(copy); diff --git a/Yafc/Widgets/ObjectTooltip.cs b/Yafc/Widgets/ObjectTooltip.cs index 3db70b0f..a6ac2a91 100644 --- a/Yafc/Widgets/ObjectTooltip.cs +++ b/Yafc/Widgets/ObjectTooltip.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + /// /// The location(s) where should display hints /// (currently only "ctrl+click to add recipe" hints) @@ -61,7 +62,7 @@ private void BuildHeader(ImGui gui) { } } - private void BuildSubHeader(ImGui gui, string text) { + private static void BuildSubHeader(ImGui gui, string text) { using (gui.EnterGroup(contentPadding)) { gui.BuildText(text, Font.subheader); } @@ -71,7 +72,7 @@ private void BuildSubHeader(ImGui gui, string text) { } } - private void BuildIconRow(ImGui gui, IReadOnlyList objects, int maxRows) { + private static void BuildIconRow(ImGui gui, IReadOnlyList objects, int maxRows) { const int itemsPerRow = 9; int count = objects.Count; if (count == 0) { @@ -115,7 +116,7 @@ private void BuildIconRow(ImGui gui, IReadOnlyList objects, int } } - private void BuildItem(ImGui gui, IFactorioObjectWrapper item) { + private static void BuildItem(ImGui gui, IFactorioObjectWrapper item) { using (gui.EnterRow()) { gui.BuildFactorioObjectIcon(item.target); gui.BuildText(item.text, TextBlockDisplayStyle.WrappedText); @@ -156,10 +157,12 @@ private void BuildCommon(FactorioObject target, ImGui gui) { } if (!target.IsAccessible()) { - gui.BuildText("This " + target.type + " is inaccessible, or it is only accessible through mod or map script. Middle click to open dependency analyzer to investigate.", TextBlockDisplayStyle.WrappedText); + string message = "This " + target.type + " is inaccessible, or it is only accessible through mod or map script. Middle click to open dependency analyzer to investigate."; + gui.BuildText(message, TextBlockDisplayStyle.WrappedText); } else if (!target.IsAutomatable()) { - gui.BuildText("This " + target.type + " cannot be fully automated. This means that it requires either manual crafting, or manual labor such as cutting trees", TextBlockDisplayStyle.WrappedText); + string message = "This " + target.type + " cannot be fully automated. This means that it requires either manual crafting, or manual labor such as cutting trees"; + gui.BuildText(message, TextBlockDisplayStyle.WrappedText); } else { gui.BuildText(CostAnalysis.GetDisplayCost(target), TextBlockDisplayStyle.WrappedText); @@ -200,7 +203,8 @@ private void BuildEntity(Entity entity, ImGui gui) { if (entity.mapGenerated) { using (gui.EnterGroup(contentPadding)) { - gui.BuildText("Generates on map (estimated density: " + (entity.mapGenDensity <= 0f ? "unknown" : DataUtils.FormatAmount(entity.mapGenDensity, UnitOfMeasure.None)) + ")", TextBlockDisplayStyle.WrappedText); + gui.BuildText("Generates on map (estimated density: " + (entity.mapGenDensity <= 0f ? "unknown" : DataUtils.FormatAmount(entity.mapGenDensity, UnitOfMeasure.None)) + ")", + TextBlockDisplayStyle.WrappedText); } } @@ -304,7 +308,7 @@ private void BuildGoods(Goods goods, ImGui gui) { using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, goods.production, 2); if (tooltipOptions.HintLocations.HasFlag(HintLocations.OnProducingRecipes)) { - goods.production.SelectSingle(out string recipeTip); + _ = goods.production.SelectSingle(out string recipeTip); gui.BuildText(recipeTip, TextBlockDisplayStyle.HintText); } } @@ -322,7 +326,7 @@ private void BuildGoods(Goods goods, ImGui gui) { using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, goods.usages, 4); if (tooltipOptions.HintLocations.HasFlag(HintLocations.OnConsumingRecipes)) { - goods.usages.SelectSingle(out string recipeTip); + _ = goods.usages.SelectSingle(out string recipeTip); gui.BuildText(recipeTip, TextBlockDisplayStyle.HintText); } } diff --git a/Yafc/Widgets/PseudoScreen.cs b/Yafc/Widgets/PseudoScreen.cs index 5ee29012..85da9565 100644 --- a/Yafc/Widgets/PseudoScreen.cs +++ b/Yafc/Widgets/PseudoScreen.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + // Pseudo screen is not an actual screen, it is a panel shown in the middle of the main screen public abstract class PseudoScreen : IKeyboardFocus { public readonly ImGui contents; @@ -55,7 +56,7 @@ protected virtual void Close() { cleanupCallback?.Invoke(); opened = false; - InputSystem.Instance.SetDefaultKeyboardFocus(null); + _ = InputSystem.Instance.SetDefaultKeyboardFocus(null); InputSystem.Instance.SetKeyboardFocus(null); MainScreen.Instance.ClosePseudoScreen(this); } @@ -87,8 +88,7 @@ public virtual void FocusChanged(bool focused) { } /// Represents a panel that can generate a result. (But doesn't have to, if the user selects a close or cancel button.) /// /// The type of result the panel can generate. -public abstract class PseudoScreenWithResult : PseudoScreen { - protected PseudoScreenWithResult(float width = 40f) : base(width) { } +public abstract class PseudoScreenWithResult(float width = 40f) : PseudoScreen(width) { /// /// If not , called after the panel is closed. The parameters are hasResult and result: If a result is available, the first parameter will /// be , and the second parameter will have the result. The result may be , depending on the kind of panel that was displayed. diff --git a/Yafc/Widgets/SearchableList.cs b/Yafc/Widgets/SearchableList.cs index cc420c4e..9bab7714 100644 --- a/Yafc/Widgets/SearchableList.cs +++ b/Yafc/Widgets/SearchableList.cs @@ -3,7 +3,10 @@ using Yafc.UI; namespace Yafc; -public class SearchableList(float height, Vector2 elementSize, VirtualScrollList.Drawer drawer, SearchableList.Filter filter, IComparer? comparer = null) : VirtualScrollList(height, elementSize, drawer) { + +public class SearchableList(float height, Vector2 elementSize, VirtualScrollList.Drawer drawer, SearchableList.Filter filter, IComparer? comparer = null) + : VirtualScrollList(height, elementSize, drawer) { + private readonly List list = []; public delegate bool Filter(TData data, SearchQuery searchTokens); @@ -11,6 +14,7 @@ public class SearchableList(float height, Vector2 elementSize, VirtualScr private readonly Filter filterFunc = filter; private IEnumerable _data = []; + // TODO (https://github.com/shpaass/yafc-ce/issues/293) investigate set() public new IEnumerable data { get => _data; set { diff --git a/Yafc/Windows/AboutScreen.cs b/Yafc/Windows/AboutScreen.cs index 6f89ecd8..3cd223e1 100644 --- a/Yafc/Windows/AboutScreen.cs +++ b/Yafc/Windows/AboutScreen.cs @@ -1,6 +1,7 @@ using Yafc.UI; namespace Yafc; + public class AboutScreen : WindowUtility { public const string Github = "https://github.com/have-fun-was-taken/yafc-ce"; @@ -14,24 +15,35 @@ protected override void BuildContents(ImGui gui) { gui.BuildText("Copyright 2024 YAFC Community", TextBlockDisplayStyle.Centered); gui.allocator = RectAllocator.LeftAlign; gui.AllocateSpacing(1.5f); - gui.BuildText("This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.", TextBlockDisplayStyle.WrappedText); - gui.BuildText("This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.", TextBlockDisplayStyle.WrappedText); + + string gnuMessage = "This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public " + + "License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version."; + gui.BuildText(gnuMessage, TextBlockDisplayStyle.WrappedText); + + string noWarrantyMessage = "This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the " + + "implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details."; + gui.BuildText(noWarrantyMessage, TextBlockDisplayStyle.WrappedText); + using (gui.EnterRow(0.3f)) { gui.BuildText("Full license text:"); BuildLink(gui, "https://gnu.org/licenses/gpl-3.0.html"); } + using (gui.EnterRow(0.3f)) { gui.BuildText("Github YAFC-CE page and documentation:"); BuildLink(gui, Github); } + gui.AllocateSpacing(1.5f); gui.BuildText("Free and open-source third-party libraries used:", Font.subheader); BuildLink(gui, "https://dotnet.microsoft.com/", "Microsoft .NET core and libraries"); + using (gui.EnterRow(0.3f)) { BuildLink(gui, "https://libsdl.org/index.php", "Simple DirectMedia Layer 2.0"); gui.BuildText("and"); BuildLink(gui, "https://github.com/flibitijibibo/SDL2-CS", "SDL2-CS"); } + using (gui.EnterRow(0.3f)) { gui.BuildText("Libraries for SDL2:"); BuildLink(gui, "http://libpng.org/pub/png/libpng.html", "libpng,"); @@ -40,6 +52,7 @@ protected override void BuildContents(ImGui gui) { gui.BuildText("and"); BuildLink(gui, "https://zlib.net/", "zlib"); } + using (gui.EnterRow(0.3f)) { gui.BuildText("Google"); BuildLink(gui, "https://developers.google.com/optimization", "OR-Tools,"); @@ -68,7 +81,7 @@ protected override void BuildContents(ImGui gui) { BuildLink(gui, "https://factorio.com/"); } - private void BuildLink(ImGui gui, string url, string? text = null) { + private static void BuildLink(ImGui gui, string url, string? text = null) { if (gui.BuildLink(text ?? url)) { Ui.VisitLink(url); } diff --git a/Yafc/Windows/DependencyExplorer.cs b/Yafc/Windows/DependencyExplorer.cs index 455903b9..fdff6ec9 100644 --- a/Yafc/Windows/DependencyExplorer.cs +++ b/Yafc/Windows/DependencyExplorer.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public class DependencyExplorer : PseudoScreen { private readonly ScrollArea dependencies; private readonly ScrollArea dependents; @@ -27,16 +28,13 @@ public class DependencyExplorer : PseudoScreen { {DependencyList.Flags.Hidden, ("", "This technology is hidden")}, }; - public DependencyExplorer(FactorioObject current) : base(60f) { dependencies = new ScrollArea(30f, DrawDependencies); dependents = new ScrollArea(30f, DrawDependants); this.current = current; } - public static void Show(FactorioObject target) { - _ = MainScreen.Instance.ShowPseudoScreen(new DependencyExplorer(target)); - } + public static void Show(FactorioObject target) => _ = MainScreen.Instance.ShowPseudoScreen(new DependencyExplorer(target)); private void DrawFactorioObject(ImGui gui, FactorioId id) { var fobj = Database.objects[id]; diff --git a/Yafc/Windows/ErrorListPanel.cs b/Yafc/Windows/ErrorListPanel.cs index 6436088b..dea80745 100644 --- a/Yafc/Windows/ErrorListPanel.cs +++ b/Yafc/Windows/ErrorListPanel.cs @@ -2,6 +2,7 @@ using Yafc.UI; namespace Yafc; + public class ErrorListPanel : PseudoScreen { private readonly ErrorCollector collector; private readonly ScrollArea verticalList; @@ -19,9 +20,7 @@ private void BuildErrorList(ImGui gui) { } } - public static void Show(ErrorCollector collector) { - _ = MainScreen.Instance.ShowPseudoScreen(new ErrorListPanel(collector)); - } + public static void Show(ErrorCollector collector) => _ = MainScreen.Instance.ShowPseudoScreen(new ErrorListPanel(collector)); public override void Build(ImGui gui) { if (collector.severity == ErrorSeverity.Critical) { BuildHeader(gui, "Loading failed"); diff --git a/Yafc/Windows/FilesystemScreen.cs b/Yafc/Windows/FilesystemScreen.cs index 21be2c53..0ed61f45 100644 --- a/Yafc/Windows/FilesystemScreen.cs +++ b/Yafc/Windows/FilesystemScreen.cs @@ -7,6 +7,7 @@ using Yafc.UI; namespace Yafc; + public class FilesystemScreen : TaskWindow, IKeyboardFocus { private enum EntryType { Drive, ParentDirectory, Directory, CreateDirectory, File } public enum Mode { @@ -27,9 +28,11 @@ public enum Mode { private readonly Func? filter; private string? selectedResult; private bool resultValid; - private IKeyboardFocus? previousFocus; + private readonly IKeyboardFocus? previousFocus; + + public FilesystemScreen(string? header, string description, string button, string? location, Mode mode, string? defaultFileName, + Window parent, Func? filter, string? extension) { - public FilesystemScreen(string? header, string description, string button, string? location, Mode mode, string? defaultFileName, Window parent, Func? filter, string? extension) { this.description = description; this.mode = mode; this.defaultFileName = defaultFileName; @@ -95,7 +98,7 @@ private void SetLocation(string directory) { data = data.Concat(files.Select(x => (EntryType.File, x))); } - entries.data = data.OrderBy(x => x.type).ThenBy(x => x.path, StringComparer.OrdinalIgnoreCase).ToArray(); + entries.data = [.. data.OrderBy(x => x.type).ThenBy(x => x.path, StringComparer.OrdinalIgnoreCase)]; } location = directory; @@ -128,7 +131,7 @@ public void UpdatePossibleResult() { rootGui.Rebuild(); } - private (Icon, string) GetDisplay((EntryType type, string location) data) => data.type switch { + private static (Icon, string) GetDisplay((EntryType type, string location) data) => data.type switch { EntryType.Directory => (Icon.Folder, Path.GetFileName(data.location)), EntryType.Drive => (Icon.FolderOpen, data.location), EntryType.ParentDirectory => (Icon.Upload, ".."), @@ -137,7 +140,7 @@ public void UpdatePossibleResult() { }; protected override void Close() { - InputSystem.Instance.SetDefaultKeyboardFocus(previousFocus); + _ = InputSystem.Instance.SetDefaultKeyboardFocus(previousFocus); base.Close(); } diff --git a/Yafc/Windows/ImageSharePanel.cs b/Yafc/Windows/ImageSharePanel.cs index efb41a79..77b176cc 100644 --- a/Yafc/Windows/ImageSharePanel.cs +++ b/Yafc/Windows/ImageSharePanel.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + public class ImageSharePanel : PseudoScreen { private readonly MemoryDrawingSurface surface; private readonly string header; @@ -34,7 +35,9 @@ public override void Build(ImGui gui) { Ui.VisitLink("file:///" + TempImageFile); } - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && gui.BuildButton(copied ? "Copied to clipboard" : "Copy to clipboard (Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_C) + ")", active: !copied)) { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + && gui.BuildButton(copied ? "Copied to clipboard" : "Copy to clipboard (Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_C) + ")", active: !copied)) { + WindowsClipboard.CopySurfaceToClipboard(surface); copied = true; } diff --git a/Yafc/Windows/MainScreen.PageListSearch.cs b/Yafc/Windows/MainScreen.PageListSearch.cs index 596f1502..6b1150a9 100644 --- a/Yafc/Windows/MainScreen.PageListSearch.cs +++ b/Yafc/Windows/MainScreen.PageListSearch.cs @@ -117,10 +117,8 @@ public IEnumerable Search(IEnumerable pages) { } } - bool isMatch(string internalName, string localizedName) { - return (searchNameMode != SearchNameMode.Internal && query.Match(localizedName)) || - (searchNameMode != SearchNameMode.Localized && query.Match(internalName)); - } + bool isMatch(string internalName, string localizedName) + => (searchNameMode != SearchNameMode.Internal && query.Match(localizedName)) || (searchNameMode != SearchNameMode.Localized && query.Match(internalName)); } } } diff --git a/Yafc/Windows/MainScreen.cs b/Yafc/Windows/MainScreen.cs index 46ddee1c..df8c0607 100644 --- a/Yafc/Windows/MainScreen.cs +++ b/Yafc/Windows/MainScreen.cs @@ -14,6 +14,7 @@ using Yafc.UI; namespace Yafc; + public partial class MainScreen : WindowMain, IKeyboardFocus, IProgress<(string, string)> { private static readonly ILogger logger = Logging.GetLogger(); ///Unique ID for the Summary page @@ -314,7 +315,7 @@ public ProjectPage AddProjectPage(string name, FactorioObject? icon, Type conten return page; } - public void BuildSubHeader(ImGui gui, string text) { + public static void BuildSubHeader(ImGui gui, string text) { using (gui.EnterGroup(ObjectTooltip.contentPadding)) { gui.BuildText(text, Font.subheader); } @@ -324,7 +325,7 @@ public void BuildSubHeader(ImGui gui, string text) { } } - private void ShowNeie() => SelectSingleObjectPanel.Select(Database.goods.explorable, "Open NEIE", NeverEnoughItemsPanel.Show); + private static void ShowNeie() => SelectSingleObjectPanel.Select(Database.goods.explorable, "Open NEIE", NeverEnoughItemsPanel.Show); private void SetSearch(SearchQuery searchQuery) { pageSearch = searchQuery; @@ -494,13 +495,13 @@ private class GithubReleaseInfo { public string html_url { get; set; } = null!; // null-forgiving: Set by Deserialize public string tag_name { get; set; } = null!; // null-forgiving: Set by Deserialize } - private async void DoCheckForUpdates() { + private static async void DoCheckForUpdates() { try { HttpClient client = new HttpClient(); client.DefaultRequestHeaders.Add("User-Agent", "YAFC-CE (check for updates)"); string result = await client.GetStringAsync(new Uri("https://api.github.com/repos/have-fun-was-taken/yafc-ce/releases/latest")); var release = JsonSerializer.Deserialize(result)!; - string version = release.tag_name.StartsWith("v", StringComparison.Ordinal) ? release.tag_name[1..] : release.tag_name; + string version = release.tag_name.StartsWith('v') ? release.tag_name[1..] : release.tag_name; if (new Version(version) > YafcLib.version) { var (_, answer) = await MessageBox.Show("New version available!", "There is a new version available: " + release.tag_name, "Visit release page", "Close"); if (answer) { @@ -656,9 +657,10 @@ private async void LoadProjectLight() { return; } - string? path = await new FilesystemScreen("Load project", "Load another .yafc project", "Select", - string.IsNullOrEmpty(project.attachedFileName) ? null : Path.GetDirectoryName(project.attachedFileName), FilesystemScreen.Mode.SelectOrCreateFile, "project", this, - null, "yafc"); + string? projectDirectory = string.IsNullOrEmpty(project.attachedFileName) ? null : Path.GetDirectoryName(project.attachedFileName); + string? path = await new FilesystemScreen("Load project", "Load another .yafc project", "Select", projectDirectory, + FilesystemScreen.Mode.SelectOrCreateFile, "project", this, null, "yafc"); + if (path == null) { return; } diff --git a/Yafc/Windows/MessageBox.cs b/Yafc/Windows/MessageBox.cs index 088ddd28..ca63808d 100644 --- a/Yafc/Windows/MessageBox.cs +++ b/Yafc/Windows/MessageBox.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc; + public class MessageBox : PseudoScreenWithResult { private readonly string title; private readonly string message; diff --git a/Yafc/Windows/MilestonesEditor.cs b/Yafc/Windows/MilestonesEditor.cs index b670000a..fea246c1 100644 --- a/Yafc/Windows/MilestonesEditor.cs +++ b/Yafc/Windows/MilestonesEditor.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + public class MilestonesEditor : PseudoScreen { private static readonly MilestonesEditor Instance = new MilestonesEditor(); private readonly VirtualScrollList milestoneList; @@ -45,13 +46,16 @@ private void MilestoneDrawer(ImGui gui, FactorioObject element, int index) { public override void Build(ImGui gui) { BuildHeader(gui, "Milestone editor"); milestoneList.Build(gui); - gui.BuildText( - "Hint: You can reorder milestones. When an object is locked behind a milestone, the first inaccessible milestone will be shown. Also when there is a choice between different milestones, first will be chosen", - TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); + + string milestoneHintText = "Hint: You can reorder milestones. When an object is locked behind a milestone, the first inaccessible milestone will be shown. " + + "Also when there is a choice between different milestones, first will be chosen"; + gui.BuildText(milestoneHintText, TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); + using (gui.EnterRow()) { if (gui.BuildButton("Auto sort milestones", SchemeColor.Grey)) { ErrorCollector collector = new ErrorCollector(); - Milestones.Instance.ComputeWithParameters(Project.current, collector, Project.current.settings.milestones.ToArray(), true); + Milestones.Instance.ComputeWithParameters(Project.current, collector, [.. Project.current.settings.milestones], true); + if (collector.severity > ErrorSeverity.None) { ErrorListPanel.Show(collector); } @@ -66,11 +70,14 @@ public override void Build(ImGui gui) { private void AddMilestone(FactorioObject obj) { var settings = Project.current.settings; + if (settings.milestones.Contains(obj)) { MessageBox.Show("Cannot add milestone", "Milestone already exists", "Ok"); return; } + var lockedMask = Milestones.Instance.GetMilestoneResult(obj); + if (lockedMask.IsClear()) { settings.RecordUndo().milestones.Add(obj); } @@ -81,6 +88,7 @@ private void AddMilestone(FactorioObject obj) { lockedMask[i] = false; var milestone = Milestones.Instance.currentMilestones[i - 1]; int index = settings.milestones.IndexOf(milestone); + if (index >= bestIndex) { bestIndex = index + 1; } @@ -92,6 +100,7 @@ private void AddMilestone(FactorioObject obj) { } settings.RecordUndo().milestones.Insert(bestIndex, obj); } + Rebuild(); milestoneList.data = settings.milestones; } diff --git a/Yafc/Windows/MilestonesPanel.cs b/Yafc/Windows/MilestonesPanel.cs index 27b9d16c..95862365 100644 --- a/Yafc/Windows/MilestonesPanel.cs +++ b/Yafc/Windows/MilestonesPanel.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc; + public class MilestonesWidget : VirtualScrollList { public MilestonesWidget() : base(30f, new Vector2(3f, 3f), MilestoneDrawer) => data = Project.current.settings.milestones; diff --git a/Yafc/Windows/NeverEnoughItemsPanel.cs b/Yafc/Windows/NeverEnoughItemsPanel.cs index b41d0e29..60889e89 100644 --- a/Yafc/Windows/NeverEnoughItemsPanel.cs +++ b/Yafc/Windows/NeverEnoughItemsPanel.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + public class NeverEnoughItemsPanel : PseudoScreen, IComparer { private static readonly NeverEnoughItemsPanel Instance = new NeverEnoughItemsPanel(); private Goods current = null!; // null-forgiving: Set by Show. @@ -91,21 +92,21 @@ private void SetItem(Goods current) { } currentFlow = current.ApproximateFlow(atCurrentMilestones); - var refreshedProductions = new RecipeEntry[current.production.Length]; + RecipeEntry[] refreshedProductions = new RecipeEntry[current.production.Length]; for (int i = 0; i < current.production.Length; i++) { refreshedProductions[i] = new RecipeEntry(current.production[i], true, current, atCurrentMilestones); } Array.Sort(refreshedProductions, this); - var refreshedUsages = new RecipeEntry[current.usages.Length]; + RecipeEntry[] refreshedUsages = new RecipeEntry[current.usages.Length]; for (int i = 0; i < current.usages.Length; i++) { refreshedUsages[i] = new RecipeEntry(current.usages[i], false, current, atCurrentMilestones); } Array.Sort(refreshedUsages, this); this.current = current; - this.productions = refreshedProductions; - this.usages = refreshedUsages; + productions = refreshedProductions; + usages = refreshedUsages; Rebuild(); productionList.Rebuild(); @@ -186,7 +187,9 @@ private void DrawRecipeEntry(ImGui gui, RecipeEntry entry, bool production) { float bh = CostAnalysis.GetBuildingHours(recipe, entry.recipeFlow); if (bh > 20) { gui.BuildText(DataUtils.FormatAmount(bh, UnitOfMeasure.None, suffix: "bh"), TextBlockDisplayStyle.Centered); - _ = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Grey).WithTooltip(gui, "Building-hours.\nAmount of building-hours required for all researches assuming crafting speed of 1"); + + _ = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Grey) + .WithTooltip(gui, "Building-hours.\nAmount of building-hours required for all researches assuming crafting speed of 1"); } } gui.AllocateSpacing(); diff --git a/Yafc/Windows/PreferencesScreen.cs b/Yafc/Windows/PreferencesScreen.cs index f001e2b5..9cafa6b5 100644 --- a/Yafc/Windows/PreferencesScreen.cs +++ b/Yafc/Windows/PreferencesScreen.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc; + public class PreferencesScreen : PseudoScreen { private static readonly PreferencesScreen Instance = new PreferencesScreen(); @@ -47,7 +48,9 @@ public override void Build(ImGui gui) { } } - using (gui.EnterRowWithHelpIcon("Some mod icons have little or no transparency, hiding the background color. This setting reduces the size of icons that could hide link information.")) { + string iconScaleMessage = "Some mod icons have little or no transparency, hiding the background color. This setting reduces the size of icons that could hide link information."; + + using (gui.EnterRowWithHelpIcon(iconScaleMessage)) { gui.BuildText("Display scale for linkable icons", topOffset: 0.5f); DisplayAmount amount = new(prefs.iconScale, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value > 0 && amount.Value <= 1) { @@ -75,7 +78,7 @@ public override void Build(ImGui gui) { using (gui.EnterRow()) { gui.BuildText("Reactor layout:", topOffset: 0.5f); if (gui.BuildTextInput(settings.reactorSizeX + "x" + settings.reactorSizeY, out string newSize, null, delayed: true)) { - int px = newSize.IndexOf("x", StringComparison.Ordinal); + int px = newSize.IndexOf('x'); if (px < 0 && int.TryParse(newSize, out int value)) { settings.RecordUndo().reactorSizeX = value; settings.reactorSizeY = value; @@ -131,7 +134,7 @@ private static void ChooseObjectWithNone(ImGui gui, string text, T[] list, T? } } - private void BuildUnitPerTime(ImGui gui, bool fluid, ProjectPreferences preferences) { + private static void BuildUnitPerTime(ImGui gui, bool fluid, ProjectPreferences preferences) { DisplayAmount unit = fluid ? preferences.fluidUnit : preferences.itemUnit; if (gui.BuildRadioButton("Simple Amount" + preferences.GetPerTimeUnit().suffix, unit == 0f)) { unit = 0f; diff --git a/Yafc/Windows/ProjectPageSettingsPanel.cs b/Yafc/Windows/ProjectPageSettingsPanel.cs index c8871820..13378e43 100644 --- a/Yafc/Windows/ProjectPageSettingsPanel.cs +++ b/Yafc/Windows/ProjectPageSettingsPanel.cs @@ -12,6 +12,7 @@ namespace Yafc; public class ProjectPageSettingsPanel : PseudoScreen { + private static readonly JsonSerializerOptions jsonSerializerOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; private readonly ProjectPage? editingPage; private string name; private FactorioObject? icon; @@ -35,9 +36,7 @@ private void Build(ImGui gui, Action setIcon) { } } - public static void Show(ProjectPage? page, Action? callback = null) { - _ = MainScreen.Instance.ShowPseudoScreen(new ProjectPageSettingsPanel(page, callback)); - } + public static void Show(ProjectPage? page, Action? callback = null) => _ = MainScreen.Instance.ShowPseudoScreen(new ProjectPageSettingsPanel(page, callback)); public override void Build(ImGui gui) { gui.spacing = 3f; @@ -124,7 +123,8 @@ private void OtherToolsDropdown(ImGui gui) { } if (editingPage == MainScreen.Instance.activePage && gui.BuildContextMenuButton("Make full page screenshot")) { - var screenshot = MainScreen.Instance.activePageView!.GenerateFullPageScreenshot(); // null-forgiving: editingPage is not null, so neither is activePage, and activePage and activePageView become null or not-null together. (see MainScreen.ChangePage) + // null-forgiving: editingPage is not null, so neither is activePage, and activePage and activePageView become null or not-null together. (see MainScreen.ChangePage) + var screenshot = MainScreen.Instance.activePageView!.GenerateFullPageScreenshot(); _ = new ImageSharePanel(screenshot, editingPage.name); _ = gui.CloseDropdown(); } @@ -203,7 +203,9 @@ private class ExportMaterial(string name, double countPerSecond) { private static void ExportPage(ProjectPage page) { using MemoryStream stream = new MemoryStream(); using Utf8JsonWriter writer = new Utf8JsonWriter(stream); - JsonSerializer.Serialize(stream, ((ProductionTable)page.content).recipes.Select(rr => new ExportRow(rr)), new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + ProductionTable pageContent = ((ProductionTable)page.content); + + JsonSerializer.Serialize(stream, pageContent.recipes.Select(rr => new ExportRow(rr)), jsonSerializerOptions); _ = SDL.SDL_SetClipboardText(Encoding.UTF8.GetString(stream.GetBuffer())); } @@ -219,7 +221,10 @@ public static void LoadProjectPageFromClipboard() { deflateStream.CopyTo(ms); byte[] bytes = ms.GetBuffer(); int index = 0; + +#pragma warning disable IDE0078 // Use pattern matching: False positive detection that changes code behavior if (DataUtils.ReadLine(bytes, ref index) != "YAFC" || DataUtils.ReadLine(bytes, ref index) != "ProjectPage") { +#pragma warning restore IDE0078 throw new InvalidDataException(); } diff --git a/Yafc/Windows/SelectMultiObjectPanel.cs b/Yafc/Windows/SelectMultiObjectPanel.cs index 635e9688..47682766 100644 --- a/Yafc/Windows/SelectMultiObjectPanel.cs +++ b/Yafc/Windows/SelectMultiObjectPanel.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public class SelectMultiObjectPanel : SelectObjectPanel> { private readonly HashSet results = []; private readonly Predicate checkMark; diff --git a/Yafc/Windows/SelectObjectPanel.cs b/Yafc/Windows/SelectObjectPanel.cs index 6d9d5ce3..de585476 100644 --- a/Yafc/Windows/SelectObjectPanel.cs +++ b/Yafc/Windows/SelectObjectPanel.cs @@ -6,6 +6,7 @@ using Yafc.UI; namespace Yafc; + /// /// Represents a panel that can generate a result by selecting zero or more s. (But doesn't have to, if the user selects a close or cancel button.) /// @@ -26,10 +27,12 @@ public abstract class SelectObjectPanel : PseudoScreenWithResult { /// /// Opens a to allow the user to select zero or more s. /// - /// or one of its derived classes, to allow and to have better type checking. + /// or one of its derived classes, to allow and + /// to have better type checking. /// The items to be displayed in this panel. /// The string that describes to the user why they're selecting these items. - /// An action to be called for each selected item when the panel is closed. The parameter may be if is . + /// An action to be called for each selected item when the panel is closed. + /// The parameter may be if is . /// An optional ordering specifying how to sort the displayed items. If , defaults to . /// An action that should convert the ? result into zero or more s, and then call its second /// parameter for each . The first parameter may be if is . @@ -73,7 +76,7 @@ private void ElementDrawer(ImGui gui, FactorioObject? element, int index) { if (element == null) { ButtonEvent evt = gui.BuildRedButton(Icon.Close); if (noneTooltip != null) { - evt.WithTooltip(gui, noneTooltip); + _ = evt.WithTooltip(gui, noneTooltip); } if (evt) { CloseWithResult(default); diff --git a/Yafc/Windows/SelectSingleObjectPanel.cs b/Yafc/Windows/SelectSingleObjectPanel.cs index 4e8ea101..b45822bb 100644 --- a/Yafc/Windows/SelectSingleObjectPanel.cs +++ b/Yafc/Windows/SelectSingleObjectPanel.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + /// /// Represents a panel that can generate a result by selecting zero or one s. (But doesn't have to, if the user selects a close or cancel button.) /// @@ -19,7 +20,8 @@ public SelectSingleObjectPanel() : base() { } /// An action to be called for the selected item when the panel is closed. /// An optional ordering specifying how to sort the displayed items. If , defaults to . public static void Select(IEnumerable list, string header, Action selectItem, IComparer? ordering = null) where T : FactorioObject - => Instance.Select(list, header, selectItem!, ordering, (obj, mappedAction) => mappedAction(obj), false); // null-forgiving: selectItem will not be called with null, because allowNone is false. + // null-forgiving: selectItem will not be called with null, because allowNone is false. + => Instance.Select(list, header, selectItem!, ordering, (obj, mappedAction) => mappedAction(obj), false); /// /// Opens a to allow the user to select one , or to clear the current selection by selecting @@ -27,7 +29,8 @@ public static void Select(IEnumerable list, string header, Action selec /// /// The items to be displayed in this panel. /// The string that describes to the user why they're selecting these items. - /// An action to be called for the selected item when the panel is closed. The parameter will be if the "none" or "clear" option is selected. + /// An action to be called for the selected item when the panel is closed. + /// The parameter will be if the "none" or "clear" option is selected. /// An optional ordering specifying how to sort the displayed items. If , defaults to . /// If not , this tooltip will be displayed when hovering over the "none" item. public static void SelectWithNone(IEnumerable list, string header, Action selectItem, IComparer? ordering = null, string? noneTooltip = null) where T : FactorioObject diff --git a/Yafc/Windows/ShoppingListScreen.cs b/Yafc/Windows/ShoppingListScreen.cs index 1fe6b8c0..e70e7f6a 100644 --- a/Yafc/Windows/ShoppingListScreen.cs +++ b/Yafc/Windows/ShoppingListScreen.cs @@ -7,6 +7,7 @@ using Yafc.UI; namespace Yafc; + public class ShoppingListScreen : PseudoScreen { private enum DisplayState { Total, Built, Missing } private readonly VirtualScrollList<(FactorioObject, float)> list; @@ -156,7 +157,9 @@ public override void Build(ImGui gui) { private void ExportBlueprintDropdown(ImGui gui) { gui.BuildText("Blueprint string will be copied to clipboard", TextBlockDisplayStyle.WrappedText); - if (Database.objectsByTypeName.TryGetValue("Entity.constant-combinator", out var combinator) && gui.BuildFactorioObjectButtonWithText(combinator) == Click.Left && gui.CloseDropdown()) { + if (Database.objectsByTypeName.TryGetValue("Entity.constant-combinator", out var combinator) + && gui.BuildFactorioObjectButtonWithText(combinator) == Click.Left && gui.CloseDropdown()) { + _ = BlueprintUtilities.ExportConstantCombinators("Shopping list", ExportGoods()); } @@ -167,7 +170,7 @@ private void ExportBlueprintDropdown(ImGui gui) { } } - private Recipe? FindSingleProduction(Recipe[] production) { + private static Recipe? FindSingleProduction(Recipe[] production) { Recipe? current = null; foreach (Recipe recipe in production) { if (recipe.IsAccessible()) { @@ -228,6 +231,6 @@ void AddDecomposition(FactorioObject obj, float amount) { } } - list.data = decomposeResult.Select(x => (x.Key, x.Value)).OrderByDescending(x => x.Value).ToArray(); + list.data = [.. decomposeResult.Select(x => (x.Key, x.Value)).OrderByDescending(x => x.Value)]; } } diff --git a/Yafc/Windows/WelcomeScreen.cs b/Yafc/Windows/WelcomeScreen.cs index 145fb24e..7e47df25 100644 --- a/Yafc/Windows/WelcomeScreen.cs +++ b/Yafc/Windows/WelcomeScreen.cs @@ -11,6 +11,7 @@ using Yafc.UI; namespace Yafc; + public class WelcomeScreen : WindowUtility, IProgress<(string, string)>, IKeyboardFocus { private readonly ILogger logger = Logging.GetLogger(); private bool loading; @@ -165,12 +166,15 @@ protected override void BuildContents(ImGui gui) { """, false)) { _ = gui.BuildCheckBox("Use net production/consumption when analyzing recipes", netProduction, out netProduction); } - using (gui.EnterRowWithHelpIcon(""" - If checked, the main project screen will not use hardware-accelerated rendering. - Enable this setting if YAFC crashes after loading without an error message, or if you know that your computer's graphics hardware does not support modern APIs (e.g. DirectX 12 on Windows). - """, false)) { + + string softwareRenderHint = "If checked, the main project screen will not use hardware-accelerated rendering.\n\n" + + "Enable this setting if YAFC crashes after loading without an error message, or if you know that your computer's " + + "graphics hardware does not support modern APIs (e.g. DirectX 12 on Windows)."; + + using (gui.EnterRowWithHelpIcon(softwareRenderHint, false)) { bool forceSoftwareRenderer = Preferences.Instance.forceSoftwareRenderer; _ = gui.BuildCheckBox("Force software rendering in project screen", forceSoftwareRenderer, out forceSoftwareRenderer); + if (forceSoftwareRenderer != Preferences.Instance.forceSoftwareRenderer) { Preferences.Instance.forceSoftwareRenderer = forceSoftwareRenderer; Preferences.Instance.Save(); @@ -202,17 +206,25 @@ protected override void BuildContents(ImGui gui) { private void ProjectErrorMoreInfo(ImGui gui) { gui.allocator = RectAllocator.LeftAlign; gui.BuildText("Check that these mods load in Factorio", TextBlockDisplayStyle.WrappedText); - gui.BuildText("YAFC only supports loading mods that were loaded in Factorio before. If you add or remove mods or change startup settings, you need to load those in Factorio and then close the game because Factorio writes some files only when exiting", TextBlockDisplayStyle.WrappedText); + + string factorioLoadedPassage = "YAFC only supports loading mods that were loaded in Factorio before. If you add or remove mods or change startup settings, " + + "you need to load those in Factorio and then close the game because Factorio writes some files only when exiting"; + gui.BuildText(factorioLoadedPassage, TextBlockDisplayStyle.WrappedText); gui.BuildText("Check that Factorio loads mods from the same folder as YAFC", TextBlockDisplayStyle.WrappedText); - gui.BuildText("If that doesn't help, try removing all the mods that are present but aren't loaded because they are disabled, don't have required dependencies, or (especially) have several versions", TextBlockDisplayStyle.WrappedText); + + string modRemovalPassage = "If that doesn't help, try removing all the mods that are present but aren't loaded because they are disabled, " + + "don't have required dependencies, or (especially) have several versions"; + gui.BuildText(modRemovalPassage, TextBlockDisplayStyle.WrappedText); + if (gui.BuildLink("If that doesn't help either, create a github issue")) { Ui.VisitLink(AboutScreen.Github); } - gui.BuildText("For these types of errors simple mod list will not be enough. You need to attach a 'New game' save game for syncing mods, mod versions and mod settings.", TextBlockDisplayStyle.WrappedText); + string gameSavePassage = "For these types of errors simple mod list will not be enough. You need to attach a 'New game' save game for syncing mods, mod versions and mod settings."; + gui.BuildText(gameSavePassage, TextBlockDisplayStyle.WrappedText); } - private void DoLanguageList(ImGui gui, Dictionary list, bool enabled) { + private static void DoLanguageList(ImGui gui, Dictionary list, bool enabled) { foreach (var (k, v) in list) { if (!enabled) { gui.BuildText(v); @@ -234,7 +246,9 @@ private void LanguageSelection(ImGui gui) { DoLanguageList(gui, languageMapping, true); if (!Program.hasOverriddenFont) { gui.AllocateSpacing(0.5f); - gui.BuildText("To select languages with non-european glyphs you need to override used font first. Download or locate a font that has your language glyphs.", TextBlockDisplayStyle.WrappedText); + + string nonEuLanguageMessage = "To select languages with non-european glyphs you need to override used font first. Download or locate a font that has your language glyphs."; + gui.BuildText(nonEuLanguageMessage, TextBlockDisplayStyle.WrappedText); gui.AllocateSpacing(0.5f); } DoLanguageList(gui, languagesRequireFontOverride, Program.hasOverriddenFont); @@ -417,9 +431,25 @@ private async void LoadProject() { }; private async void ShowFileSelect(string description, string path, EditType type) { - string? result = await new FilesystemScreen("Select folder", description, type == EditType.Workspace ? "Select" : "Select folder", type == EditType.Workspace ? Path.GetDirectoryName(path) : path, - type == EditType.Workspace ? FilesystemScreen.Mode.SelectOrCreateFile : FilesystemScreen.Mode.SelectFolder, "", this, GetFolderFilter(type), - type == EditType.Workspace ? "yafc" : null); + string buttonText; + string? location, fileExtension; + FilesystemScreen.Mode fsMode; + + if (type == EditType.Workspace) { + buttonText = "Select"; + location = Path.GetDirectoryName(path); + fsMode = FilesystemScreen.Mode.SelectOrCreateFile; + fileExtension = "yafc"; + } + else { + buttonText = "Select folder"; + location = path; + fsMode = FilesystemScreen.Mode.SelectFolder; + fileExtension = null; + } + + string? result = await new FilesystemScreen("Select folder", description, buttonText, location, fsMode, "", this, GetFolderFilter(type), fileExtension); + if (result != null) { if (type == EditType.Factorio) { dataPath = result; diff --git a/Yafc/Windows/WizardPanel.cs b/Yafc/Windows/WizardPanel.cs index 9139a80a..7c744144 100644 --- a/Yafc/Windows/WizardPanel.cs +++ b/Yafc/Windows/WizardPanel.cs @@ -5,6 +5,7 @@ #nullable disable warnings // Disabling nullable for legacy code. namespace Yafc; + public class WizardPanel : PseudoScreen { public static readonly WizardPanel Instance = new WizardPanel(); diff --git a/Yafc/Workspace/AutoPlannerView.cs b/Yafc/Workspace/AutoPlannerView.cs index 9c560c11..728ed414 100644 --- a/Yafc/Workspace/AutoPlannerView.cs +++ b/Yafc/Workspace/AutoPlannerView.cs @@ -6,6 +6,7 @@ #nullable disable warnings // Disabling nullable for legacy code. namespace Yafc; + public class AutoPlannerView : ProjectPageView { private AutoPlannerRecipe selectedRecipe; @@ -27,7 +28,8 @@ private Action CreateAutoPlannerWizard(List pages) { string pageName = "Auto planner"; void Page1(ImGui gui, ref bool valid) { - gui.BuildText("This is an experimental feature and may lack functionality. Unfortunately, after some prototyping it wasn't very useful to work with. More research required.", TextBlockDisplayStyle.ErrorText); + gui.BuildText("This is an experimental feature and may lack functionality. Unfortunately, after some prototyping it wasn't very useful to work with. More research required.", + TextBlockDisplayStyle.ErrorText); gui.BuildText("Enter page name:"); _ = gui.BuildTextInput(pageName, out pageName, null); gui.AllocateSpacing(2f); @@ -81,15 +83,19 @@ protected override void BuildContent(ImGui gui) { foreach (var tier in model.tiers) { using var grid = gui.EnterInlineGrid(3f); + foreach (var recipe in tier) { var color = SchemeColor.None; + if (gui.isBuilding) { if (selectedRecipe != null && ((selectedRecipe.downstream != null && selectedRecipe.downstream.Contains(recipe.recipe)) || (selectedRecipe.upstream != null && selectedRecipe.upstream.Contains(recipe.recipe)))) { color = SchemeColor.Secondary; } } + grid.Next(); + if (gui.BuildFactorioObjectWithAmount(recipe.recipe, new(recipe.recipesPerSecond, UnitOfMeasure.PerSecond), ButtonDisplayStyle.ProductionTableScaled(color)) == Click.Left) { selectedRecipe = recipe; } diff --git a/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs b/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs index ec932a54..46487365 100644 --- a/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs +++ b/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public class ProductionSummaryView : ProjectPageView { private readonly DataGrid grid; private readonly FlatHierarchy flatHierarchy; @@ -22,11 +23,7 @@ public ProductionSummaryView() { flatHierarchy = new FlatHierarchy(grid, null, buildExpandedGroupRows: true); } - private class PaddingColumn : DataColumn { - private readonly ProductionSummaryView view; - - public PaddingColumn(ProductionSummaryView view) : base(3f) => this.view = view; - + private class PaddingColumn(ProductionSummaryView view) : DataColumn(3f) { public override void BuildHeader(ImGui gui) { } public override void BuildElement(ImGui gui, ProductionSummaryEntry row) { @@ -118,7 +115,9 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry entry) { gui.allocator = RectAllocator.LeftRow; gui.BuildText("x"); DisplayAmount amount = entry.multiplier; - if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.FactorioObjectInput with { ColorGroup = SchemeColorGroup.Grey, Alignment = RectAlignment.MiddleLeft }) && amount.Value >= 0) { + if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.FactorioObjectInput with { ColorGroup = SchemeColorGroup.Grey, Alignment = RectAlignment.MiddleLeft }) + && amount.Value >= 0) { + entry.SetMultiplier(amount.Value); } } @@ -146,25 +145,24 @@ protected override void ModelContentsChanged(bool visualOnly) { RebuildColumns(); } - private class GoodsColumn : DataColumn { - public readonly ProductionSummaryColumn column; - private readonly ProductionSummaryView view; - public Goods goods => column.goods; + private class GoodsColumn(ProductionSummaryColumn column, ProductionSummaryView view) : DataColumn(4f) { + public readonly ProductionSummaryColumn column = column; - public GoodsColumn(ProductionSummaryColumn column, ProductionSummaryView view) : base(4f) { - this.column = column; - this.view = view; - } + public Goods goods => column.goods; public override void BuildHeader(ImGui gui) { var moveHandle = gui.statePosition; moveHandle.Height = 5f; - if (gui.BuildFactorioObjectWithAmount(goods, new(view.model.GetTotalFlow(goods), goods.flowUnitOfMeasure), ButtonDisplayStyle.ProductionTableScaled(view.filteredGoods == goods ? SchemeColor.Primary : SchemeColor.None)) == Click.Left) { + if (gui.BuildFactorioObjectWithAmount(goods, new(view.model.GetTotalFlow(goods), goods.flowUnitOfMeasure), + ButtonDisplayStyle.ProductionTableScaled(view.filteredGoods == goods ? SchemeColor.Primary : SchemeColor.None)) == Click.Left) { + view.ApplyFilter(goods); } - if (!gui.InitiateDrag(moveHandle, moveHandle, column) && gui.ConsumeDrag(moveHandle.Center, column) && gui.GetDraggingObject() is ProductionSummaryColumn draggingColumn) { + if (!gui.InitiateDrag(moveHandle, moveHandle, column) && gui.ConsumeDrag(moveHandle.Center, column) + && gui.GetDraggingObject() is ProductionSummaryColumn draggingColumn) { + view.model.RecordUndo(true).columns.MoveListElement(draggingColumn, column); view.RebuildColumns(); } @@ -180,10 +178,7 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry data) { } } - private class RestGoodsColumn : TextDataColumn { - private readonly ProductionSummaryView view; - public RestGoodsColumn(ProductionSummaryView view) : base("Other", 30f, 5f, 40f) => this.view = view; - + private class RestGoodsColumn(ProductionSummaryView view) : TextDataColumn("Other", 30f, 5f, 40f) { public override void BuildElement(ImGui gui, ProductionSummaryEntry data) { using var grid = gui.EnterInlineGrid(2.1f); foreach (var (goods, amount) in data.flow) { @@ -295,7 +290,9 @@ protected override void BuildContent(ImGui gui) { using var inlineGrid = gui.EnterInlineGrid(3f, 1f); foreach (var (goods, amount) in model.sortedFlow) { inlineGrid.Next(); - if (gui.BuildFactorioObjectWithAmount(goods, new(amount, goods.flowUnitOfMeasure), ButtonDisplayStyle.ProductionTableScaled(model.columnsExist.Contains(goods) ? SchemeColor.Primary : SchemeColor.None)) == Click.Left) { + if (gui.BuildFactorioObjectWithAmount(goods, new(amount, goods.flowUnitOfMeasure), + ButtonDisplayStyle.ProductionTableScaled(model.columnsExist.Contains(goods) ? SchemeColor.Primary : SchemeColor.None)) == Click.Left) { + AddOrRemoveColumn(goods); } } diff --git a/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs b/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs index eb4a4739..a345e836 100644 --- a/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs +++ b/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public class ModuleCustomizationScreen : PseudoScreenWithResult { private static readonly ModuleCustomizationScreen Instance = new ModuleCustomizationScreen(); @@ -63,10 +64,12 @@ public override void Build(ImGui gui) { } grid.Next(); if (gui.BuildButton(Icon.Plus, SchemeColor.Primary, SchemeColor.PrimaryAlt, size: 1.5f)) { - SelectSingleObjectPanel.Select(Database.allCrafters.Where(x => x.allowedEffects != AllowedEffects.None && !template.filterEntities.Contains(x)), "Add module template filter", sel => { - template.RecordUndo().filterEntities.Add(sel); - gui.Rebuild(); - }); + // TODO (shpaass/yafc-ce/issues/256): unwrap it into something more readable + SelectSingleObjectPanel.Select(Database.allCrafters.Where(x => x.allowedEffects != AllowedEffects.None && !template.filterEntities.Contains(x)), + "Add module template filter", sel => { + template.RecordUndo().filterEntities.Add(sel); + gui.Rebuild(); + }); } } if (modules == null) { @@ -101,7 +104,8 @@ public override void Build(ImGui gui) { SelectBeacon(gui); } - gui.BuildText("Input the amount of modules, not the amount of beacons. Single beacon can hold " + modules.beacon.moduleSlots + " modules.", TextBlockDisplayStyle.WrappedText); + string modulesNotBeacons = "Input the amount of modules, not the amount of beacons. Single beacon can hold " + modules.beacon.moduleSlots + " modules."; + gui.BuildText(modulesNotBeacons, TextBlockDisplayStyle.WrappedText); DrawRecipeModules(gui, modules.beacon, ref effects); } @@ -109,8 +113,11 @@ public override void Build(ImGui gui) { float craftingSpeed = (recipe.entity?.craftingSpeed ?? 1f) * effects.speedMod; gui.BuildText("Current effects:", Font.subheader); gui.BuildText("Productivity bonus: " + DataUtils.FormatAmount(effects.productivity, UnitOfMeasure.Percent)); - gui.BuildText("Speed bonus: " + DataUtils.FormatAmount(effects.speedMod - 1, UnitOfMeasure.Percent) + " (Crafting speed: " + DataUtils.FormatAmount(craftingSpeed, UnitOfMeasure.None) + ")"); + gui.BuildText("Speed bonus: " + DataUtils.FormatAmount(effects.speedMod - 1, UnitOfMeasure.Percent) + " (Crafting speed: " + + DataUtils.FormatAmount(craftingSpeed, UnitOfMeasure.None) + ")"); + string energyUsageLine = "Energy usage: " + DataUtils.FormatAmount(effects.energyUsageMod, UnitOfMeasure.Percent); + if (recipe.entity != null) { float power = effects.energyUsageMod * recipe.entity.power / recipe.entity.energy.effectivity; if (!recipe.recipe.flags.HasFlagAny(RecipeFlags.UsesFluidTemperature | RecipeFlags.ScaleProductionWithPower) && recipe.entity != null) { diff --git a/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs b/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs index e3b7d283..9492f7cc 100644 --- a/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs +++ b/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs @@ -6,6 +6,7 @@ using Yafc.UI; namespace Yafc; + public class ModuleFillerParametersScreen : PseudoScreen { private static readonly float ModulesMinPayback = MathF.Log(600f); private static readonly float ModulesMaxPayback = MathF.Log(3600f * 120f); @@ -31,29 +32,36 @@ private void ListDrawer(ImGui gui, KeyValuePair { + if (selectedBeacon is null) { - modules.overrideCrafterBeacons.Remove(crafter); + _ = modules.overrideCrafterBeacons.Remove(crafter); } else { modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beacon = selectedBeacon }; + if (!selectedBeacon.CanAcceptModule(modules.overrideCrafterBeacons[crafter].beaconModule.moduleSpecification)) { _ = Database.GetDefaultModuleFor(selectedBeacon, out Module? module); - modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconModule = module! }; // null-forgiving: Anything from usableBeacons accepts at least one module. + // null-forgiving: Anything from usableBeacons accepts at least one module. + modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconModule = module! }; } } + overrideList.data = [.. modules.overrideCrafterBeacons]; }, noneTooltip: "Click here to remove the current override."); break; case GoodsWithAmountEvent.RightButtonClick: - SelectSingleObjectPanel.SelectWithNone(Database.allModules.Where(m => modules.overrideCrafterBeacons[crafter].beacon.CanAcceptModule(m.moduleSpecification)), "Select beacon module", selectedModule => { - if (selectedModule is null) { - modules.overrideCrafterBeacons.Remove(crafter); - } - else { - modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconModule = selectedModule }; - } - overrideList.data = [.. modules.overrideCrafterBeacons]; - }, noneTooltip: "Click here to remove the current override."); + SelectSingleObjectPanel.SelectWithNone(Database.allModules.Where(m => modules.overrideCrafterBeacons[crafter].beacon.CanAcceptModule(m.moduleSpecification)), + "Select beacon module", selectedModule => { + + if (selectedModule is null) { + _ = modules.overrideCrafterBeacons.Remove(crafter); + } + else { + modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconModule = selectedModule }; + } + + overrideList.data = [.. modules.overrideCrafterBeacons]; + }, noneTooltip: "Click here to remove the current override."); break; case GoodsWithAmountEvent.TextEditing when amount.Value >= 0: modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconCount = (int)amount.Value }; @@ -101,7 +109,7 @@ public override void Build(ImGui gui) { gui.BuildText("Filler module:", Font.subheader); gui.BuildText("Use this module when aufofill doesn't add anything (for example when productivity modules doesn't fit)", TextBlockDisplayStyle.WrappedText); if (gui.BuildFactorioObjectButtonWithText(modules.fillerModule) == Click.Left) { - SelectSingleObjectPanel.SelectWithNone(Database.allModules, "Select filler module", select => { modules.fillerModule = select; }); + SelectSingleObjectPanel.SelectWithNone(Database.allModules, "Select filler module", select => modules.fillerModule = select); } gui.AllocateSpacing(); @@ -122,12 +130,14 @@ public override void Build(ImGui gui) { } if (gui.BuildFactorioObjectButtonWithText(modules.beaconModule) == Click.Left) { - SelectSingleObjectPanel.SelectWithNone(Database.allModules.Where(x => modules.beacon?.CanAcceptModule(x.moduleSpecification) ?? false), "Select module for beacon", select => { modules.beaconModule = select; }); + SelectSingleObjectPanel.SelectWithNone(Database.allModules.Where(x => modules.beacon?.CanAcceptModule(x.moduleSpecification) ?? false), + "Select module for beacon", select => modules.beaconModule = select); } using (gui.EnterRow()) { gui.BuildText("Beacons per building: "); DisplayAmount amount = modules.beaconsPerBuilding; + if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.ModuleParametersTextInput) && (int)amount.Value > 0) { modules.beaconsPerBuilding = (int)amount.Value; } @@ -136,6 +146,7 @@ public override void Build(ImGui gui) { gui.AllocateSpacing(); gui.BuildText("Override beacons:", Font.subheader); + if (modules.overrideCrafterBeacons.Count > 0) { using (gui.EnterGroup(new Padding(1, 0, 0, 0))) { gui.BuildText("Click to change beacon, right-click to change module", topOffset: -0.5f); @@ -147,9 +158,11 @@ public override void Build(ImGui gui) { using (gui.EnterRow(allocator: RectAllocator.Center)) { if (gui.BuildButton("Add an override for a building type")) { - SelectMultiObjectPanel.Select(Database.allCrafters.Where(x => x.allowedEffects != AllowedEffects.None && !modules.overrideCrafterBeacons.ContainsKey(x)), "Add exception(s) for:", + SelectMultiObjectPanel.Select(Database.allCrafters.Where(x => x.allowedEffects != AllowedEffects.None && !modules.overrideCrafterBeacons.ContainsKey(x)), + "Add exception(s) for:", crafter => { - modules.overrideCrafterBeacons[crafter] = new BeaconOverrideConfiguration(modules.beacon ?? defaultBeacon, modules.beaconsPerBuilding, modules.beaconModule ?? defaultBeaconModule); + modules.overrideCrafterBeacons[crafter] = new BeaconOverrideConfiguration(modules.beacon ?? defaultBeacon, modules.beaconsPerBuilding, + modules.beaconModule ?? defaultBeaconModule); overrideList.data = [.. modules.overrideCrafterBeacons]; }); } diff --git a/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs b/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs index 2d164150..52486b3d 100644 --- a/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs +++ b/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs @@ -3,6 +3,7 @@ using Yafc.UI; namespace Yafc; + public class ModuleTemplateConfiguration : PseudoScreen { private static readonly ModuleTemplateConfiguration Instance = new ModuleTemplateConfiguration(); private readonly VirtualScrollList templateList; diff --git a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs index eb180eb6..0edbacc4 100644 --- a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs +++ b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + public class ProductionLinkSummaryScreen : PseudoScreen, IComparer<(RecipeRow row, float flow)> { private readonly ProductionLink link; private readonly List<(RecipeRow row, float flow)> input = []; @@ -25,12 +26,14 @@ private void BuildScrollArea(ImGui gui) { BuildFlow(gui, output, totalOutput); if (link.amount != 0) { gui.spacing = 0.5f; - gui.BuildText((link.amount > 0 ? "Requested production: " : "Requested consumption: ") + DataUtils.FormatAmount(MathF.Abs(link.amount), link.goods.flowUnitOfMeasure), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.GreenAlt)); + gui.BuildText((link.amount > 0 ? "Requested production: " : "Requested consumption: ") + DataUtils.FormatAmount(MathF.Abs(link.amount), + link.goods.flowUnitOfMeasure), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.GreenAlt)); } if (link.flags.HasFlags(ProductionLink.Flags.LinkNotMatched) && totalInput != totalOutput + link.amount) { float amount = totalInput - totalOutput - link.amount; gui.spacing = 0.5f; - gui.BuildText((amount > 0 ? "Overproduction: " : "Overconsumption: ") + DataUtils.FormatAmount(MathF.Abs(amount), link.goods.flowUnitOfMeasure), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.Error)); + gui.BuildText((amount > 0 ? "Overproduction: " : "Overconsumption: ") + DataUtils.FormatAmount(MathF.Abs(amount), link.goods.flowUnitOfMeasure), + new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.Error)); } } @@ -80,9 +83,7 @@ private void CalculateFlow(ProductionLink link) { scrollArea.RebuildContents(); } - public static void Show(ProductionLink link) { - _ = MainScreen.Instance.ShowPseudoScreen(new ProductionLinkSummaryScreen(link)); - } + public static void Show(ProductionLink link) => _ = MainScreen.Instance.ShowPseudoScreen(new ProductionLinkSummaryScreen(link)); public int Compare((RecipeRow row, float flow) x, (RecipeRow row, float flow) y) => y.flow.CompareTo(x.flow); } diff --git a/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs b/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs index bd3a74d0..ad743631 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs @@ -4,6 +4,7 @@ using Yafc.UI; namespace Yafc; + /// /// /// This is a flat hierarchy that can be used to display a table with nested groups in a single list. @@ -16,8 +17,9 @@ namespace Yafc; /// GUI system. /// /// -public class FlatHierarchy where TRow : ModelObject, IGroupedElement where TGroup : ModelObject, IElementGroup { - private readonly DataGrid grid; +public class FlatHierarchy(DataGrid grid, Action? drawTableHeader, string emptyGroupMessage = "This is an empty group", bool buildExpandedGroupRows = true) + where TRow : ModelObject, IGroupedElement where TGroup : ModelObject, IElementGroup { + // These two arrays contain: // - (recipe, null) for rows with no subgroup or with a collapsed subgroup // - (recipe, subgroup) for rows with an expanded subgroup @@ -28,16 +30,6 @@ public class FlatHierarchy where TRow : ModelObject, IGrou private TRow? draggingRecipe; private TGroup root = null!; // null-forgiving: root is set by SetData whenever the selected page is set or the chevrons are clicked. private bool rebuildRequired; - private readonly Action? drawTableHeader; - private readonly string emptyGroupMessage; - private readonly bool buildExpandedGroupRows; - - public FlatHierarchy(DataGrid grid, Action? drawTableHeader, string emptyGroupMessage = "This is an empty group", bool buildExpandedGroupRows = true) { - this.grid = grid; - this.drawTableHeader = drawTableHeader; - this.emptyGroupMessage = emptyGroupMessage; - this.buildExpandedGroupRows = buildExpandedGroupRows; - } public float width => grid.width; public void SetData(TGroup table) { @@ -60,7 +52,8 @@ public void SetData(TGroup table) { } } else { - i = flatRecipes.LastIndexOf(flatGroups[i]!.owner as TRow, i); // null-forgiving: The construction of flatRows and flatGroups guarantees they aren't both null at the same index. + // null-forgiving: The construction of flatRows and flatGroups guarantees they aren't both null at the same index. + i = flatRecipes.LastIndexOf(flatGroups[i]!.owner as TRow, i); } currentIndex++; @@ -185,7 +178,8 @@ public void Build(ImGui gui) { draggingRecipe = recipe; } else if (gui.ConsumeDrag(rect.Center, recipe)) { - MoveFlatHierarchy(gui.GetDraggingObject()!, recipe); // null-forgiving: currentDraggingObject is set to recipe (a non-null TRow, despite several checks for RecipeRow) by InitiateDrag + // null-forgiving: currentDraggingObject is set to recipe (a non-null TRow, despite several checks for RecipeRow) by InitiateDrag + MoveFlatHierarchy(gui.GetDraggingObject()!, recipe); } if (nextRowIsHighlighted || isError) { diff --git a/Yafc/Workspace/ProductionTable/ProductionTableView.cs b/Yafc/Workspace/ProductionTable/ProductionTableView.cs index f2bad8af..c5c55790 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableView.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableView.cs @@ -9,18 +9,23 @@ using Yafc.UI; namespace Yafc; + public class ProductionTableView : ProjectPageView { private readonly FlatHierarchy flatHierarchyBuilder; public ProductionTableView() { - DataGrid grid = new DataGrid(new RecipePadColumn(this), new RecipeColumn(this), new EntityColumn(this), new IngredientsColumn(this), new ProductsColumn(this), new ModulesColumn(this)); - flatHierarchyBuilder = new FlatHierarchy(grid, BuildSummary, "This is a nested group. You can drag&drop recipes here. Nested groups can have their own linked materials."); + DataGrid grid = new DataGrid(new RecipePadColumn(this), new RecipeColumn(this), new EntityColumn(this), + new IngredientsColumn(this), new ProductsColumn(this), new ModulesColumn(this)); + + flatHierarchyBuilder = new FlatHierarchy(grid, BuildSummary, + "This is a nested group. You can drag&drop recipes here. Nested groups can have their own linked materials."); } /// If not , names an instance property in that will be used to store the width of this column. /// If the current value of the property is out of range, the initial width will be . private abstract class ProductionTableDataColumn(ProductionTableView view, string header, float initialWidth, float minWidth = 0, float maxWidth = 0, bool hasMenu = true, string? widthStorage = null) : TextDataColumn(header, initialWidth, minWidth, maxWidth, hasMenu, widthStorage) { + protected readonly ProductionTableView view = view; } @@ -28,6 +33,7 @@ private class RecipePadColumn(ProductionTableView view) : ProductionTableDataCol public override void BuildElement(ImGui gui, RecipeRow row) { gui.allocator = RectAllocator.Center; gui.spacing = 0f; + if (row.subgroup != null) { if (gui.BuildButton(row.subgroup.expanded ? Icon.ShevronDown : Icon.ShevronRight)) { if (InputSystem.Instance.control) { @@ -41,10 +47,10 @@ public override void BuildElement(ImGui gui, RecipeRow row) { } } - if (row.warningFlags != 0) { bool isError = row.warningFlags >= WarningFlags.EntityNotSpecified; bool hover; + if (isError) { hover = gui.BuildRedButton(Icon.Error, invertedColors: true) == ButtonEvent.MouseOver; } @@ -55,6 +61,7 @@ public override void BuildElement(ImGui gui, RecipeRow row) { hover = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.Grey) == ButtonEvent.MouseOver; } + if (hover) { gui.ShowTooltip(g => { if (isError) { @@ -83,16 +90,18 @@ static void toggleAll(bool state, ProductionTable table) { } } - private void BuildRowMarker(ImGui gui, RecipeRow row) { + private static void BuildRowMarker(ImGui gui, RecipeRow row) { int markerId = row.tag; + if (markerId < 0 || markerId >= tagIcons.Length) { markerId = 0; } var (icon, color) = tagIcons[markerId]; gui.BuildIcon(icon, color: color); + if (gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.BackgroundAlt)) { - gui.ShowDropDown(imGui => view.DrawRecipeTagSelect(imGui, row)); + gui.ShowDropDown(imGui => DrawRecipeTagSelect(imGui, row)); } } } @@ -103,14 +112,14 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { switch (gui.BuildFactorioObjectButton(recipe.recipe, ButtonDisplayStyle.ProductionTableUnscaled)) { case Click.Left: gui.ShowDropDown(delegate (ImGui imgui) { - view.DrawRecipeTagSelect(imgui, recipe); + DrawRecipeTagSelect(imgui, recipe); if (recipe.subgroup == null && imgui.BuildButton("Create nested table") && imgui.CloseDropdown()) { recipe.RecordUndo().subgroup = new ProductionTable(recipe); } if (recipe.subgroup != null && imgui.BuildButton("Add nested desired product") && imgui.CloseDropdown()) { - view.AddDesiredProductAtLevel(recipe.subgroup); + AddDesiredProductAtLevel(recipe.subgroup); } if (recipe.subgroup != null) { @@ -165,6 +174,7 @@ void unpackNestedTable() { _ = recipe.subgroup.RecordUndo(); recipe.RecordUndo().subgroup = null; int index = recipe.owner.recipes.IndexOf(recipe); + foreach (var evacRecipe in evacuate) { evacRecipe.SetOwner(recipe.owner); } @@ -173,8 +183,9 @@ void unpackNestedTable() { } } - private void RemoveZeroRecipes(ProductionTable productionTable) { + private static void RemoveZeroRecipes(ProductionTable productionTable) { _ = productionTable.RecordUndo().recipes.RemoveAll(x => x.subgroup == null && x.recipesPerSecond == 0); + foreach (var recipe in productionTable.recipes) { if (recipe.subgroup != null) { RemoveZeroRecipes(recipe.subgroup); @@ -188,6 +199,7 @@ public override void BuildMenu(ImGui gui) { gui.BuildText("Export inputs and outputs to blueprint with constant combinators:", TextBlockDisplayStyle.WrappedText); using (gui.EnterRow()) { gui.BuildText("Amount per:"); + if (gui.BuildLink("second") && gui.CloseDropdown()) { ExportIo(1f); } @@ -239,10 +251,12 @@ public override void BuildMenu(ImGui gui) { private static void BuildRecipeButton(ImGui gui, ProductionTable table) { if (gui.BuildButton("Add raw recipe").WithTooltip(gui, "Ctrl-click to add a technology instead") && gui.CloseDropdown()) { if (InputSystem.Instance.control) { - SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", r => table.AddRecipe(r, DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe == r)); + SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", + r => table.AddRecipe(r, DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe == r)); } else { - SelectMultiObjectPanel.Select(Database.recipes.all, "Select raw recipe", r => table.AddRecipe(r, DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe == r)); + SelectMultiObjectPanel.Select(Database.recipes.all, "Select raw recipe", + r => table.AddRecipe(r, DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe == r)); } } } @@ -251,6 +265,7 @@ private void ExportIo(float multiplier) { List<(Goods, int)> goods = []; foreach (var link in view.model.links) { int rounded = MathUtils.Round(link.amount * multiplier); + if (rounded == 0) { continue; } @@ -260,6 +275,7 @@ private void ExportIo(float multiplier) { foreach (var flow in view.model.flow) { int rounded = MathUtils.Round(flow.amount * multiplier); + if (rounded == 0) { continue; } @@ -283,6 +299,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { if (recipe.fixedBuildings > 0 && !recipe.fixedFuel && recipe.fixedIngredient == null && recipe.fixedProduct == null) { DisplayAmount amount = recipe.fixedBuildings; GoodsWithAmountEvent evt = gui.BuildFactorioObjectWithEditableAmount(recipe.entity, amount, ButtonDisplayStyle.ProductionTableUnscaled); + if (evt == GoodsWithAmountEvent.TextEditing && amount.Value >= 0) { recipe.RecordUndo().fixedBuildings = amount.Value; } @@ -295,6 +312,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { if (recipe.builtBuildings != null) { DisplayAmount amount = recipe.builtBuildings.Value; + if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.FactorioObjectInput with { ColorGroup = SchemeColorGroup.Grey }) && amount.Value >= 0) { recipe.RecordUndo().builtBuildings = (int)amount.Value; } @@ -308,10 +326,13 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { ShowEntityDropdown(gui, recipe); } else if (click == Click.Right) { - EntityCrafter favoriteCrafter = recipe.recipe.crafters.AutoSelect(DataUtils.FavoriteCrafter)!; // null-forgiving: We know recipe.recipe.crafters is not empty, so AutoSelect can't return null. + // null-forgiving: We know recipe.recipe.crafters is not empty, so AutoSelect can't return null. + EntityCrafter favoriteCrafter = recipe.recipe.crafters.AutoSelect(DataUtils.FavoriteCrafter)!; + if (favoriteCrafter != null && recipe.entity != favoriteCrafter) { _ = recipe.RecordUndo(); recipe.entity = favoriteCrafter; + if (!recipe.entity.energy.fuels.Contains(recipe.fuel)) { recipe.fuel = recipe.entity.energy.fuels.AutoSelect(DataUtils.FavoriteFuel); } @@ -345,11 +366,10 @@ private static void BuildSolarPanelAccumulatorView(ImGui gui, RecipeRow recipe) } } - private static void ShowAccumulatorDropdown(ImGui gui, RecipeRow recipe, Entity currentAccumulator) => gui.ShowDropDown(imGui => { - imGui.BuildInlineObjectListAndButton(Database.allAccumulators, DataUtils.DefaultOrdering, + private static void ShowAccumulatorDropdown(ImGui gui, RecipeRow recipe, Entity currentAccumulator) => gui.ShowDropDown(imGui + => imGui.BuildInlineObjectListAndButton(Database.allAccumulators, DataUtils.DefaultOrdering, newAccumulator => recipe.RecordUndo().ChangeVariant(currentAccumulator, newAccumulator), "Select accumulator", - extra: x => DataUtils.FormatAmount(x.accumulatorCapacity, UnitOfMeasure.Megajoule)); - }); + extra: x => DataUtils.FormatAmount(x.accumulatorCapacity, UnitOfMeasure.Megajoule))); private static void ShowEntityDropdown(ImGui imgui, RecipeRow recipe) => imgui.ShowDropDown(gui => { EntityCrafter? favoriteCrafter = recipe.recipe.crafters.AutoSelect(DataUtils.FavoriteCrafter); @@ -380,13 +400,18 @@ private static void ShowEntityDropdown(ImGui imgui, RecipeRow recipe) => imgui.S } } - using (gui.EnterRowWithHelpIcon("Tell YAFC how many buildings it must use when solving this page.\nUse this to ask questions like 'What does it take to handle the output of ten miners?'")) { + string fixedBuildingsTip = "Tell YAFC how many buildings it must use when solving this page.\n" + + "Use this to ask questions like 'What does it take to handle the output of ten miners?'"; + + using (gui.EnterRowWithHelpIcon(fixedBuildingsTip)) { gui.allocator = RectAllocator.RemainingRow; if (recipe.fixedBuildings > 0f && !recipe.fixedFuel && recipe.fixedIngredient == null && recipe.fixedProduct == null) { ButtonEvent evt = gui.BuildButton("Clear fixed building count"); + if (willResetFixed) { _ = evt.WithTooltip(gui, "Shortcut: right-click"); } + if (evt && gui.CloseDropdown()) { recipe.RecordUndo().fixedBuildings = 0f; } @@ -401,11 +426,14 @@ private static void ShowEntityDropdown(ImGui imgui, RecipeRow recipe) => imgui.S using (gui.EnterRowWithHelpIcon("Tell YAFC how many of these buildings you have in your factory.\nYAFC will warn you if you need to build more buildings.")) { gui.allocator = RectAllocator.RemainingRow; + if (recipe.builtBuildings != null) { ButtonEvent evt = gui.BuildButton("Clear built building count"); + if (willResetBuilt) { _ = evt.WithTooltip(gui, "Shortcut: right-click"); } + if (evt && gui.CloseDropdown()) { recipe.RecordUndo().builtBuildings = null; } @@ -418,15 +446,19 @@ private static void ShowEntityDropdown(ImGui imgui, RecipeRow recipe) => imgui.S if (recipe.entity != null) { using (gui.EnterRowWithHelpIcon("Generate a blueprint for one of these buildings, with the recipe and internal modules set.")) { gui.allocator = RectAllocator.RemainingRow; + if (gui.BuildButton("Create single building blueprint") && gui.CloseDropdown()) { BlueprintEntity entity = new BlueprintEntity { index = 1, name = recipe.entity.name }; + if (recipe.recipe is not Mechanics) { entity.recipe = recipe.recipe.name; } var modules = recipe.usedModules.modules; + if (modules != null) { entity.items = []; + foreach (var (module, count, beacon) in modules) { if (!beacon) { entity.items[module.name] = count; @@ -448,10 +480,12 @@ public override void BuildMenu(ImGui gui) { if (gui.BuildButton("Mass set assembler") && gui.CloseDropdown()) { SelectSingleObjectPanel.Select(Database.allCrafters, "Set assembler for all recipes", set => { DataUtils.FavoriteCrafter.AddToFavorite(set, 10); + foreach (var recipe in view.GetRecipesRecursive()) { if (recipe.recipe.crafters.Contains(set)) { _ = recipe.RecordUndo(); recipe.entity = set; + if (!set.energy.fuels.Contains(recipe.fuel)) { recipe.fuel = recipe.entity.energy.fuels.AutoSelect(DataUtils.FavoriteFuel); } @@ -463,6 +497,7 @@ public override void BuildMenu(ImGui gui) { if (gui.BuildButton("Mass set fuel") && gui.CloseDropdown()) { SelectSingleObjectPanel.Select(Database.goods.all.Where(x => x.fuelValue > 0), "Set fuel for all recipes", set => { DataUtils.FavoriteFuel.AddToFavorite(set, 10); + foreach (var recipe in view.GetRecipesRecursive()) { if (recipe.entity != null && recipe.entity.energy.fuels.Contains(set)) { recipe.RecordUndo().fuel = set; @@ -480,6 +515,7 @@ public override void BuildMenu(ImGui gui) { private class IngredientsColumn(ProductionTableView view) : ProductionTableDataColumn(view, "Ingredients", 32f, 16f, 100f, hasMenu: false, nameof(Preferences.ingredientsColumWidth)) { public override void BuildElement(ImGui gui, RecipeRow recipe) { var grid = gui.EnterInlineGrid(3f, 1f); + if (recipe.isOverviewMode) { view.BuildTableIngredients(gui, recipe.subgroup, recipe.owner, ref grid); } @@ -518,6 +554,7 @@ public ModulesColumn(ProductionTableView view) : base(view, "Modules", 10f, 7f, private void ModuleTemplateDrawer(ImGui gui, ProjectModuleTemplate element, int index) { var evt = gui.BuildContextMenuButton(element.name, icon: element.icon?.icon ?? default, disabled: !element.template.IsCompatibleWith(editingRecipeModules)); + if (evt == ButtonEvent.Click && gui.CloseDropdown()) { var copied = JsonUtils.Copy(element.template, editingRecipeModules, null); editingRecipeModules.RecordUndo().modules = copied; @@ -543,9 +580,11 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { } else { bool wasBeacon = false; + foreach (var (module, count, beacon) in recipe.usedModules.modules) { if (beacon && !wasBeacon) { wasBeacon = true; + if (recipe.usedModules.beacon != null) { drawItem(gui, recipe.usedModules.beacon, recipe.usedModules.beaconCount); } @@ -592,7 +631,8 @@ private void ShowModuleDropDown(ImGui gui, RecipeRow recipe) { var modules = recipe.recipe.modules.Where(x => recipe.entity?.CanAcceptModule(x.moduleSpecification) ?? false).ToArray(); editingRecipeModules = recipe; moduleTemplateList.data = [.. Project.current.sharedModuleTemplates - .Where(x => x.filterEntities.Count == 0 || x.filterEntities.Contains(recipe.entity!)) // null-forgiving: non-nullable collections are happy to report they don't contain null values. + // null-forgiving: non-nullable collections are happy to report they don't contain null values. + .Where(x => x.filterEntities.Count == 0 || x.filterEntities.Contains(recipe.entity!)) .OrderByDescending(x => x.template.IsCompatibleWith(recipe))]; gui.ShowDropDown(dropGui => { @@ -648,7 +688,8 @@ public static void BuildFavorites(ImGui imgui, FactorioObject? obj, string promp public static void CreateProductionSheet() => ProjectPageSettingsPanel.Show(null, (name, icon) => MainScreen.Instance.AddProjectPage(name, icon, typeof(ProductionTable), true, true)); - private static readonly IComparer DefaultVariantOrdering = new DataUtils.FactorioObjectComparer((x, y) => (y.ApproximateFlow() / MathF.Abs(y.Cost())).CompareTo(x.ApproximateFlow() / MathF.Abs(x.Cost()))); + private static readonly IComparer DefaultVariantOrdering = + new DataUtils.FactorioObjectComparer((x, y) => (y.ApproximateFlow() / MathF.Abs(y.Cost())).CompareTo(x.ApproximateFlow() / MathF.Abs(x.Cost()))); private enum ProductDropdownType { DesiredProduct, @@ -675,7 +716,7 @@ private void DestroyLink(ProductionLink link) { } } - private void CreateNewProductionTable(Goods goods, float amount) { + private static void CreateNewProductionTable(Goods goods, float amount) { var page = MainScreen.Instance.AddProjectPage(goods.locName, goods, typeof(ProductionTable), true, false); ProductionTable content = (ProductionTable)page.content; ProductionLink link = new ProductionLink(content, goods) { amount = amount > 0 ? amount : 1 }; @@ -683,7 +724,9 @@ private void CreateNewProductionTable(Goods goods, float amount) { content.RebuildLinkMap(); } - private void OpenProductDropdown(ImGui targetGui, Rect rect, Goods goods, float amount, ProductionLink? link, ProductDropdownType type, RecipeRow? recipe, ProductionTable context, Goods[]? variants = null) { + private void OpenProductDropdown(ImGui targetGui, Rect rect, Goods goods, float amount, ProductionLink? link, + ProductDropdownType type, RecipeRow? recipe, ProductionTable context, Goods[]? variants = null) { + if (InputSystem.Instance.shift) { Project.current.preferences.SetSourceResource(goods, !goods.IsSourceResource()); targetGui.Rebuild(); @@ -692,12 +735,12 @@ private void OpenProductDropdown(ImGui targetGui, Rect rect, Goods goods, float var comparer = DataUtils.GetRecipeComparerFor(goods); HashSet allRecipes = new HashSet(context.recipes.Select(x => x.recipe)); - bool recipeExists(RecipeOrTechnology rec) { - return allRecipes.Contains(rec); - } + + bool recipeExists(RecipeOrTechnology rec) => allRecipes.Contains(rec); Goods? selectedFuel = null; Goods? spentFuel = null; + async void addRecipe(RecipeOrTechnology rec) { if (variants == null) { CreateLink(context, goods); @@ -706,14 +749,18 @@ async void addRecipe(RecipeOrTechnology rec) { foreach (var variant in variants) { if (rec.GetProductionPerRecipe(variant) > 0f) { CreateLink(context, variant); + if (variant != goods) { - recipe!.RecordUndo().ChangeVariant(goods, variant); // null-forgiving: If variants is not null, neither is recipe: Only the call from BuildGoodsIcon sets variants, and the only call to BuildGoodsIcon that sets variants also sets recipe. + // null-forgiving: If variants is not null, neither is recipe: Only the call from BuildGoodsIcon sets variants, + // and the only call to BuildGoodsIcon that sets variants also sets recipe. + recipe!.RecordUndo().ChangeVariant(goods, variant); } break; } } } + if (!allRecipes.Contains(rec) || (await MessageBox.Show("Recipe already exists", $"Add a second copy of {rec.locName}?", "Add a copy", "Cancel")).choice) { context.AddRecipe(rec, DefaultVariantOrdering, selectedFuel, spentFuel); } @@ -722,6 +769,7 @@ async void addRecipe(RecipeOrTechnology rec) { if (InputSystem.Instance.control) { bool isInput = type <= ProductDropdownType.Ingredient; var recipeList = isInput ? goods.production : goods.usages; + if (recipeList.SelectSingle(out _) is Recipe selected) { addRecipe(selected); return; @@ -729,13 +777,15 @@ async void addRecipe(RecipeOrTechnology rec) { } Recipe[] allProduction = variants == null ? goods.production : variants.SelectMany(x => x.production).Distinct().ToArray(); - Recipe[] fuelUseList = goods.fuelFor.OfType() + + Recipe[] fuelUseList = [.. goods.fuelFor.OfType() .SelectMany(e => e.recipes).OfType() - .Distinct().OrderBy(e => e, DataUtils.DefaultRecipeOrdering).ToArray(); - Recipe[] spentFuelRecipes = goods.miscSources.OfType() + .Distinct().OrderBy(e => e, DataUtils.DefaultRecipeOrdering)]; + + Recipe[] spentFuelRecipes = [.. goods.miscSources.OfType() .SelectMany(e => e.fuelFor.OfType()) .SelectMany(e => e.recipes).OfType() - .Distinct().OrderBy(e => e, DataUtils.DefaultRecipeOrdering).ToArray(); + .Distinct().OrderBy(e => e, DataUtils.DefaultRecipeOrdering)]; targetGui.ShowDropDown(rect, dropDownContent, new Padding(1f), 25f); @@ -762,9 +812,14 @@ void dropDownContent(ImGui gui) { using (var grid = gui.EnterInlineGrid(3f)) { foreach (var variant in variants) { grid.Next(); - if (gui.BuildFactorioObjectButton(variant, ButtonDisplayStyle.ProductionTableScaled(variant == goods ? SchemeColor.Primary : SchemeColor.None), tooltipOptions: HintLocations.OnProducingRecipes) == Click.Left && - variant != goods) { - recipe!.RecordUndo().ChangeVariant(goods, variant); // null-forgiving: If variants is not null, neither is recipe: Only the call from BuildGoodsIcon sets variants, and the only call to BuildGoodsIcon that sets variants also sets recipe. + + if (gui.BuildFactorioObjectButton(variant, ButtonDisplayStyle.ProductionTableScaled(variant == goods ? SchemeColor.Primary : SchemeColor.None), + tooltipOptions: HintLocations.OnProducingRecipes) == Click.Left && variant != goods) { + + // null-forgiving: If variants is not null, neither is recipe: Only the call from BuildGoodsIcon sets variants, + // and the only call to BuildGoodsIcon that sets variants also sets recipe. + recipe!.RecordUndo().ChangeVariant(goods, variant); + if (recipe!.fixedIngredient == goods) { recipe.fixedIngredient = variant; } @@ -784,18 +839,22 @@ void dropDownContent(ImGui gui) { #region Recipe selection int numberOfShownRecipes = 0; + if (goods.name == SpecialNames.ResearchUnit) { if (gui.BuildButton("Add technology") && gui.CloseDropdown()) { - SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", r => context.AddRecipe(r, DefaultVariantOrdering), checkMark: r => context.recipes.Any(rr => rr.recipe == r)); + SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", + r => context.AddRecipe(r, DefaultVariantOrdering), checkMark: r => context.recipes.Any(rr => rr.recipe == r)); } } else if (type <= ProductDropdownType.Ingredient && allProduction.Length > 0) { gui.BuildInlineObjectListAndButton(allProduction, comparer, addRecipe, "Add production recipe", 6, true, recipeExists); numberOfShownRecipes += allProduction.Length; + if (link == null) { Rect iconRect = new Rect(gui.lastRect.Right - 2f, gui.lastRect.Top, 2f, 2f); gui.DrawIcon(iconRect.Expand(-0.2f), Icon.OpenNew, gui.textColor); var evt = gui.BuildButton(iconRect, SchemeColor.None, SchemeColor.Grey); + if (evt == ButtonEvent.Click && gui.CloseDropdown()) { CreateNewProductionTable(goods, amount); } @@ -844,7 +903,8 @@ void dropDownContent(ImGui gui) { if (type >= ProductDropdownType.Product && Database.allSciencePacks.Contains(goods) && gui.BuildButton("Add consumption technology") && gui.CloseDropdown()) { // Select from the technologies that consume this science pack. - SelectMultiObjectPanel.Select(Database.technologies.all.Where(t => t.ingredients.Select(i => i.goods).Contains(goods)), "Add technology", addRecipe, checkMark: recipeExists); + SelectMultiObjectPanel.Select(Database.technologies.all.Where(t => t.ingredients.Select(i => i.goods).Contains(goods)), + "Add technology", addRecipe, checkMark: recipeExists); } if (type >= ProductDropdownType.Product && allProduction.Length > 0) { @@ -871,7 +931,8 @@ void dropDownContent(ImGui gui) { gui.BuildText(goods.locName + " is a desired product and cannot be unlinked.", TextBlockDisplayStyle.WrappedText); } else { - gui.BuildText(goods.locName + " production is currently linked. This means that YAFC will try to match production with consumption.", TextBlockDisplayStyle.WrappedText); + string goodProdLinkedMessage = goods.locName + " production is currently linked. This means that YAFC will try to match production with consumption."; + gui.BuildText(goodProdLinkedMessage, TextBlockDisplayStyle.WrappedText); } if (type is ProductDropdownType.DesiredIngredient or ProductDropdownType.DesiredProduct) { @@ -889,10 +950,13 @@ void dropDownContent(ImGui gui) { } else if (goods != null) { if (link != null) { - gui.BuildText(goods.locName + " production is currently linked, but the link is outside this nested table. Nested tables can have its own separate set of links", TextBlockDisplayStyle.WrappedText); + string goodsNestLinkMessage = goods.locName + " production is currently linked, but the link is outside this nested table. " + + "Nested tables can have its own separate set of links"; + gui.BuildText(goodsNestLinkMessage, TextBlockDisplayStyle.WrappedText); } else { - gui.BuildText(goods.locName + " production is currently NOT linked. This means that YAFC will make no attempt to match production with consumption.", TextBlockDisplayStyle.WrappedText); + string notLinkedMessage = goods.locName + " production is currently NOT linked. This means that YAFC will make no attempt to match production with consumption."; + gui.BuildText(notLinkedMessage, TextBlockDisplayStyle.WrappedText); } if (gui.BuildButton("Create link").WithTooltip(gui, "Shortcut: right-click") && gui.CloseDropdown()) { @@ -1007,7 +1071,8 @@ private void DrawDesiredProduct(ImGui gui, ProductionLink element) { DisplayAmount amount = new(element.amount, element.goods.flowUnitOfMeasure); switch (gui.BuildFactorioObjectWithEditableAmount(element.goods, amount, ButtonDisplayStyle.ProductionTableScaled(iconColor), tooltipOptions: tooltipOptions)) { case GoodsWithAmountEvent.LeftButtonClick: - OpenProductDropdown(gui, gui.lastRect, element.goods, element.amount, element, element.amount < 0 ? ProductDropdownType.DesiredIngredient : ProductDropdownType.DesiredProduct, null, element.owner); + OpenProductDropdown(gui, gui.lastRect, element.goods, element.amount, element, + element.amount < 0 ? ProductDropdownType.DesiredIngredient : ProductDropdownType.DesiredProduct, null, element.owner); break; case GoodsWithAmountEvent.RightButtonClick: DestroyLink(element); @@ -1023,8 +1088,11 @@ public override void Rebuild(bool visualOnly = false) { base.Rebuild(visualOnly); } - private void BuildGoodsIcon(ImGui gui, Goods? goods, ProductionLink? link, float amount, ProductDropdownType dropdownType, RecipeRow? recipe, ProductionTable context, ObjectTooltipOptions tooltipOptions, Goods[]? variants = null) { + private void BuildGoodsIcon(ImGui gui, Goods? goods, ProductionLink? link, float amount, ProductDropdownType dropdownType, + RecipeRow? recipe, ProductionTable context, ObjectTooltipOptions tooltipOptions, Goods[]? variants = null) { + SchemeColor iconColor; + if (link != null) { // The icon is part of a production link if ((link.flags & (ProductionLink.Flags.HasProductionAndConsumption | ProductionLink.Flags.LinkRecursiveNotMatched | ProductionLink.Flags.ChildNotMatched)) != ProductionLink.Flags.HasProductionAndConsumption) { @@ -1053,6 +1121,7 @@ private void BuildGoodsIcon(ImGui gui, Goods? goods, ProductionLink? link, float // TODO: See https://github.com/have-fun-was-taken/yafc-ce/issues/91 // and https://github.com/have-fun-was-taken/yafc-ce/pull/86#discussion_r1550377021 SchemeColor textColor = flatHierarchyBuilder.nextRowTextColor; + if (!flatHierarchyBuilder.nextRowIsHighlighted) { textColor = SchemeColor.None; } @@ -1078,7 +1147,8 @@ private void BuildGoodsIcon(ImGui gui, Goods? goods, ProductionLink? link, float evt = gui.BuildFactorioObjectWithEditableAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor), tooltipOptions: tooltipOptions); } else { - evt = (GoodsWithAmountEvent)gui.BuildFactorioObjectWithAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor), TextBlockDisplayStyle.Centered with { Color = textColor }, tooltipOptions: tooltipOptions); + evt = (GoodsWithAmountEvent)gui.BuildFactorioObjectWithAmount(goods, displayAmount, ButtonDisplayStyle.ProductionTableScaled(iconColor), + TextBlockDisplayStyle.Centered with { Color = textColor }, tooltipOptions: tooltipOptions); } switch (evt) { @@ -1107,18 +1177,23 @@ private void BuildGoodsIcon(ImGui gui, Goods? goods, ProductionLink? link, float /// If , this call is for collapsed recipe row. /// If not , this will be called before drawing the first element. This method may choose not to draw /// some or all of a table's extra products, and this lets the caller suppress the surrounding UI elements if no product end up being drawn. - private void BuildTableProducts(ImGui gui, ProductionTable table, ProductionTable context, ref ImGuiUtils.InlineGridBuilder grid, bool isForSummary, Action? initializeDrawArea = null) { + private void BuildTableProducts(ImGui gui, ProductionTable table, ProductionTable context, ref ImGuiUtils.InlineGridBuilder grid, + bool isForSummary, Action? initializeDrawArea = null) { + var flow = table.flow; int firstProduct = Array.BinarySearch(flow, new ProductionTableFlow(Database.voidEnergy, 1e-9f, null), model); + if (firstProduct < 0) { firstProduct = ~firstProduct; } for (int i = firstProduct; i < flow.Length; i++) { float amt = flow[i].amount; + if (isForSummary) { amt -= flow[i].link?.amount ?? 0; } + if (amt <= 0f) { continue; } @@ -1131,16 +1206,17 @@ private void BuildTableProducts(ImGui gui, ProductionTable table, ProductionTabl } } - private void FillRecipeList(ProductionTable table, List list) { + private static void FillRecipeList(ProductionTable table, List list) { foreach (var recipe in table.recipes) { list.Add(recipe); + if (recipe.subgroup != null) { FillRecipeList(recipe.subgroup, list); } } } - private void FillLinkList(ProductionTable table, List list) { + private static void FillLinkList(ProductionTable table, List list) { list.AddRange(table.links); foreach (var recipe in table.recipes) { if (recipe.subgroup != null) { @@ -1155,8 +1231,9 @@ private List GetRecipesRecursive() { return list; } - private List GetRecipesRecursive(RecipeRow recipeRoot) { + private static List GetRecipesRecursive(RecipeRow recipeRoot) { List list = [recipeRoot]; + if (recipeRoot.subgroup != null) { FillRecipeList(recipeRoot.subgroup, list); } @@ -1166,10 +1243,11 @@ private List GetRecipesRecursive(RecipeRow recipeRoot) { private void BuildShoppingList(RecipeRow? recipeRoot) => ShoppingListScreen.Show(recipeRoot == null ? GetRecipesRecursive() : GetRecipesRecursive(recipeRoot)); - private void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) { + private static void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) { var prefs = Project.current.preferences; var belt = prefs.defaultBelt; var inserter = prefs.defaultInserter; + if (belt == null || inserter == null) { return; } @@ -1181,6 +1259,7 @@ private void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) using (gui.EnterRow()) { click |= gui.BuildFactorioObjectButton(belt, ButtonDisplayStyle.Default) == Click.Left; gui.BuildText(DataUtils.FormatAmount(beltCount, UnitOfMeasure.None)); + if (buildingsPerHalfBelt > 0f) { gui.BuildText("(Buildings per half belt: " + DataUtils.FormatAmount(buildingsPerHalfBelt, UnitOfMeasure.None) + ")"); } @@ -1191,11 +1270,13 @@ private void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) float inserterBase = inserter.inserterSwingTime * amount / capacity; click |= gui.BuildFactorioObjectButton(inserter, ButtonDisplayStyle.Default) == Click.Left; string text = DataUtils.FormatAmount(inserterBase, UnitOfMeasure.None); + if (buildingCount > 1) { text += " (" + DataUtils.FormatAmount(inserterBase / buildingCount, UnitOfMeasure.None) + "/building)"; } gui.BuildText(text); + if (capacity > 1) { float withBeltSwingTime = inserter.inserterSwingTime + (2f * (capacity - 1.5f) / belt.beltItemsPerSecond); float inserterToBelt = amount * withBeltSwingTime / capacity; @@ -1203,6 +1284,7 @@ private void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) gui.AllocateSpacing(-1.5f); click |= gui.BuildFactorioObjectButton(inserter, ButtonDisplayStyle.Default) == Click.Left; text = DataUtils.FormatAmount(inserterToBelt, UnitOfMeasure.None, "~"); + if (buildingCount > 1) { text += " (" + DataUtils.FormatAmount(inserterToBelt / buildingCount, UnitOfMeasure.None) + "/b)"; } @@ -1227,17 +1309,19 @@ private void BuildTableIngredients(ImGui gui, ProductionTable table, ProductionT } } - private void DrawRecipeTagSelect(ImGui gui, RecipeRow recipe) { + private static void DrawRecipeTagSelect(ImGui gui, RecipeRow recipe) { using (gui.EnterRow()) { for (int i = 0; i < tagIcons.Length; i++) { var (icon, color) = tagIcons[i]; bool selected = i == recipe.tag; gui.BuildIcon(icon, color: selected ? SchemeColor.Background : color); + if (selected) { gui.DrawRectangle(gui.lastRect, color); } else { var evt = gui.BuildButton(gui.lastRect, SchemeColor.None, SchemeColor.BackgroundAlt, SchemeColor.BackgroundAlt); + if (evt) { recipe.RecordUndo(true).tag = i; } @@ -1284,7 +1368,8 @@ protected override void BuildPageTooltip(ImGui gui, ProductionTable contents) { private static readonly Dictionary WarningsMeaning = new Dictionary { {WarningFlags.DeadlockCandidate, "Contains recursive links that cannot be matched. No solution exists."}, - {WarningFlags.OverproductionRequired, "This model cannot be solved exactly, it requires some overproduction. You can allow overproduction for any link. This recipe contains one of the possible candidates."}, + {WarningFlags.OverproductionRequired, "This model cannot be solved exactly, it requires some overproduction. You can allow overproduction for any link. " + + "This recipe contains one of the possible candidates."}, {WarningFlags.EntityNotSpecified, "Crafter not specified. Solution is inaccurate." }, {WarningFlags.FuelNotSpecified, "Fuel not specified. Solution is inaccurate." }, {WarningFlags.FuelWithTemperatureNotLinked, "This recipe uses fuel with temperature. Should link with producing entity to determine temperature."}, @@ -1294,7 +1379,8 @@ protected override void BuildPageTooltip(ImGui gui, ProductionTable contents) { {WarningFlags.TemperatureForIngredientNotMatch, "This recipe does care about ingredient temperature, and the temperature range does not match"}, {WarningFlags.ReactorsNeighborsFromPrefs, "Assumes reactor formation from preferences"}, {WarningFlags.AssumesNauvisSolarRatio, "Energy production values assumes Nauvis solar ration (70% power output). Don't forget accumulators."}, - {WarningFlags.RecipeTickLimit, "Production is limited to 60 recipes per second (1/tick). This interacts weirdly with productivity bonus - actual productivity may be imprecise and may depend on your setup - test your setup before committing to it."}, + {WarningFlags.RecipeTickLimit, "Production is limited to 60 recipes per second (1/tick). This interacts weirdly with productivity bonus - " + + "actual productivity may be imprecise and may depend on your setup - test your setup before committing to it."}, {WarningFlags.ExceedsBuiltCount, "This recipe requires more buildings than are currently built."} }; @@ -1310,8 +1396,6 @@ private static readonly (Icon icon, SchemeColor color)[] tagIcons = [ (Icon.Settings, SchemeColor.BackgroundText), ]; - - protected override void BuildContent(ImGui gui) { if (model == null) { return; @@ -1323,7 +1407,7 @@ protected override void BuildContent(ImGui gui) { gui.SetMinWidth(flatHierarchyBuilder.width); } - private void AddDesiredProductAtLevel(ProductionTable table) => SelectMultiObjectPanel.Select( + private static void AddDesiredProductAtLevel(ProductionTable table) => SelectMultiObjectPanel.Select( Database.goods.all.Except(table.linkMap.Where(p => p.Value.amount != 0).Select(p => p.Key)), "Add desired product", product => { if (table.linkMap.TryGetValue(product, out var existing)) { if (existing.amount != 0) { @@ -1361,6 +1445,7 @@ private void BuildSummary(ImGui gui, ProductionTable table) { AddDesiredProductAtLevel(table); } } + if (gui.isBuilding) { gui.DrawRectangle(gui.lastRect, SchemeColor.Background, RectangleBorder.Thin); } @@ -1372,6 +1457,7 @@ private void BuildSummary(ImGui gui, ProductionTable table) { BuildTableIngredients(gui, table, table, ref grid); grid.Dispose(); } + if (gui.isBuilding) { gui.DrawRectangle(gui.lastRect, SchemeColor.Background, RectangleBorder.Thin); } diff --git a/Yafc/Workspace/ProjectPageView.cs b/Yafc/Workspace/ProjectPageView.cs index 3df6ffe6..529f097f 100644 --- a/Yafc/Workspace/ProjectPageView.cs +++ b/Yafc/Workspace/ProjectPageView.cs @@ -5,6 +5,7 @@ using Yafc.UI; namespace Yafc; + public abstract class ProjectPageView : Scrollable { protected ProjectPageView() : base(true, true, false) { headerContent = new ImGui(BuildHeader, default, RectAllocator.LeftAlign); @@ -48,6 +49,7 @@ public void Build(ImGui gui, Vector2 visibleSize) { var headerRect = gui.AllocateRect(visibleSize.X, headerHeight); position.Y += headerHeight; var contentSize = bodyContent.CalculateState(visibleSize.X - ScrollbarSize, gui.pixelsPerUnit); + if (contentSize.X > contentWidth) { contentWidth = contentSize.X; } @@ -85,6 +87,7 @@ public MemoryDrawingSurface GenerateFullPageScreenshot() { bodyContent.offset = Vector2.Zero; bodyContent.Present(surface, bodyRect, bodyRect, null); bodyContent.offset = prevOffset; + return surface; } } @@ -107,6 +110,7 @@ public override void SetModel(ProjectPage? page) { InputSystem.Instance.SetKeyboardFocus(this); projectPage = page; model = (T)page?.content!; // TODO, null-forgiving: This can clearly be null, but lots of things assume it can't. + if (model != null && projectPage != null) { projectPage.contentChanged += ModelContentsChanged; ModelContentsChanged(false); diff --git a/Yafc/Workspace/SummaryView.cs b/Yafc/Workspace/SummaryView.cs index 69bafa09..34545f4e 100644 --- a/Yafc/Workspace/SummaryView.cs +++ b/Yafc/Workspace/SummaryView.cs @@ -8,6 +8,7 @@ using Yafc.UI; namespace Yafc; + public class SummaryView : ProjectPageView { /// Some padding to have the contents of the first column not 'stick' to the rest of the UI private readonly Padding FirstColumnPadding = new Padding(1f, 1.5f, 0, 0); @@ -34,6 +35,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { using (gui.EnterGroup(new Padding(0.5f, 0.2f, 0.2f, 0.5f))) { gui.spacing = 0.2f; + if (page.icon != null) { gui.BuildIcon(page.icon.icon, FirstColumnIconSize); } @@ -46,11 +48,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { } } - private sealed class SummaryDataColumn : TextDataColumn { - private readonly SummaryView view; - - public SummaryDataColumn(SummaryView view) : base("Linked", float.MaxValue) => this.view = view; - + private sealed class SummaryDataColumn(SummaryView view) : TextDataColumn("Linked", float.MaxValue) { public override void BuildElement(ImGui gui, ProjectPage page) { if (page?.contentType != typeof(ProductionTable)) { return; @@ -61,6 +59,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { foreach ((string name, GoodDetails details, bool enoughProduced) in view.GoodsToDisplay()) { ProductionLink? link = table.links.Find(x => x.goods.name == name); grid.Next(); + if (link != null) { if (link.amount != 0f) { DrawProvideProduct(gui, link, page, details, enoughProduced); @@ -69,6 +68,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { else { if (Array.Exists(table.flow, x => x.goods.name == name)) { ProductionTableFlow flow = Array.Find(table.flow, x => x.goods.name == name); + if (Math.Abs(flow.amount) > Epsilon) { DrawRequestProduct(gui, flow, enoughProduced); @@ -80,6 +80,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { private static void DrawProvideProduct(ImGui gui, ProductionLink element, ProjectPage page, GoodDetails goodInfo, bool enoughProduced) { SchemeColor iconColor; + if (element.amount > 0 && enoughProduced) { // Production matches consumption iconColor = SchemeColor.Primary; @@ -97,11 +98,12 @@ private static void DrawProvideProduct(ImGui gui, ProductionLink element, Projec gui.spacing = 0f; DisplayAmount amount = new(element.amount, element.goods.flowUnitOfMeasure); GoodsWithAmountEvent evt = gui.BuildFactorioObjectWithEditableAmount(element.goods, amount, ButtonDisplayStyle.ProductionTableScaled(iconColor)); + if (evt == GoodsWithAmountEvent.TextEditing && amount.Value != 0) { - SetProviderAmount(element, page, amount.Value); + _ = SetProviderAmount(element, page, amount.Value); } else if (evt == GoodsWithAmountEvent.LeftButtonClick) { - SetProviderAmount(element, page, YafcRounding(goodInfo.sum)); + _ = SetProviderAmount(element, page, YafcRounding(goodInfo.sum)); } } private static void DrawRequestProduct(ImGui gui, ProductionTableFlow flow, bool enoughExtraProduced) { @@ -161,11 +163,13 @@ private struct GoodDetails { float amountAvailable = YafcRounding((goodInfo.Value.totalProvided > 0 ? goodInfo.Value.totalProvided : 0) + goodInfo.Value.extraProduced); float amountNeeded = YafcRounding((goodInfo.Value.totalProvided < 0 ? -goodInfo.Value.totalProvided : 0) + goodInfo.Value.totalNeeded); + if (model == null || (model.showOnlyIssues && (Math.Abs(amountAvailable - amountNeeded) < Epsilon || amountNeeded == 0))) { continue; } bool enoughProduced = amountAvailable >= amountNeeded; + yield return (goodInfo.Key, goodInfo.Value, enoughProduced); } } @@ -174,6 +178,7 @@ private struct GoodDetails { foreach (KeyValuePair goodInfo in allGoods) { float amountAvailable = YafcRounding((goodInfo.Value.totalProvided > 0 ? goodInfo.Value.totalProvided : 0) + goodInfo.Value.extraProduced); float amountNeeded = YafcRounding((goodInfo.Value.totalProvided < 0 ? -goodInfo.Value.totalProvided : 0) + goodInfo.Value.totalNeeded); + if (Math.Abs(amountAvailable - amountNeeded) < Epsilon || amountNeeded == 0) { continue; } @@ -184,11 +189,7 @@ private struct GoodDetails { public SummaryView(Project project) { goodsColumn = new SummaryDataColumn(this); - TextDataColumn[] columns = new TextDataColumn[] - { - new SummaryTabColumn(), - goodsColumn, - }; + TextDataColumn[] columns = [new SummaryTabColumn(), goodsColumn]; scrollArea = new SummaryScrollArea(BuildScrollArea); mainGrid = new DataGrid(columns); @@ -199,14 +200,15 @@ public SummaryView(Project project) { public void SetProject(Project project) { if (this.project != null) { this.project.metaInfoChanged -= Recalculate; + foreach (ProjectPage page in this.project.pages) { page.contentChanged -= Recalculate; } } this.project = project; - project.metaInfoChanged += Recalculate; + foreach (ProjectPage page in project.pages) { page.contentChanged += Recalculate; } @@ -229,6 +231,7 @@ private float CalculateFirstColumWidth(ImGui gui) { foreach (Guid displayPage in project.displayPages) { ProjectPage? page = project.FindPage(displayPage); + if (page?.contentType != typeof(ProductionTable)) { continue; } @@ -243,7 +246,8 @@ private float CalculateFirstColumWidth(ImGui gui) { protected override async void BuildContent(ImGui gui) { using (gui.EnterRow()) { - gui.AllocateRect(0, 2); // Increase row height to 2, for vertical centering. + _ = gui.AllocateRect(0, 2); // Increase row height to 2, for vertical centering. + if (gui.BuildCheckBox("Only show issues", model?.showOnlyIssues ?? false, out bool newValue)) { model!.showOnlyIssues = newValue; // null-forgiving: when model is null, the page is no longer being displayed, so no clicks can happen. Recalculate(); @@ -270,11 +274,13 @@ private async Task AutoBalance() { Queue updateOrder = []; Dictionary previousUpdates = []; int negativeFeedbackCount = 0; + for (int i = 0; i < 1000; i++) { // No matter what else happens, give up after 1000 clicks. float? oldExcess = null; GoodDetails details; float excess; ProductionLink? link; + while (true) { // Look for a product that (a) has a mismatch, (b) has exactly one link in the displayed pages, and (c) hasn't been updated yet. (details, excess, link) = GoodsToBalance() @@ -282,14 +288,16 @@ private async Task AutoBalance() { .Select(x => (x.details, x.excess, link: DisplayTables.Select(t => t.links.FirstOrDefault(l => l.goods.name == x.name && l.amount != 0)).WhereNotNull().SingleOrDefault(false))) // Find an item with exactly one link that hasn't been updated yet. (Or has been removed to allow it to be updated again.) .FirstOrDefault(x => x.link != null && !previousUpdates.ContainsKey(x.link)); + if (link != null) { break; } + if (updateOrder.Count == 0) { return; } // If nothing was found, allow the least-recently updated product to be updated again, but remember its previous state so we can tell if we're making progress. ProductionLink removedLink = updateOrder.Dequeue(); oldExcess = previousUpdates[removedLink]; - previousUpdates.Remove(removedLink); + _ = previousUpdates.Remove(removedLink); } if (oldExcess - Math.Abs(excess) <= Epsilon) { @@ -336,9 +344,11 @@ private void BuildScrollArea(ImGui gui) { private void Recalculate(bool visualOnly) { Dictionary newGoods = []; + foreach (Guid displayPage in project.displayPages) { ProjectPage? page = project.FindPage(displayPage); ProductionTable? content = page?.content as ProductionTable; + if (content == null) { continue; } @@ -374,6 +384,7 @@ private void Recalculate(bool visualOnly) { foreach (KeyValuePair entry in newGoods) { float amountAvailable = YafcRounding((entry.Value.totalProvided > 0 ? entry.Value.totalProvided : 0) + entry.Value.extraProduced); float amountNeeded = YafcRounding((entry.Value.totalProvided < 0 ? -entry.Value.totalProvided : 0) + entry.Value.totalNeeded); + if (model != null && model.showOnlyIssues && (Math.Abs(amountAvailable - amountNeeded) < Epsilon || amountNeeded == 0)) { continue; } diff --git a/Yafc/YafcLib.cs b/Yafc/YafcLib.cs index 8fda1471..a8fb0656 100644 --- a/Yafc/YafcLib.cs +++ b/Yafc/YafcLib.cs @@ -10,6 +10,7 @@ using Yafc.UI; namespace Yafc; + public static class YafcLib { public static Version version { get; private set; } public static string initialWorkDir;