diff --git a/.github/workflows/buildcheck-debug.yml b/.github/workflows/buildcheck-debug.yml index eae17bde..d7593401 100644 --- a/.github/workflows/buildcheck-debug.yml +++ b/.github/workflows/buildcheck-debug.yml @@ -35,6 +35,11 @@ jobs: - name: Setup MSBuild.exe uses: microsoft/setup-msbuild@v2 + # For reasons that are unclear, the build fails if the generated files do not exist at the beginning of the build. + # Explicitly build the generator to generate the files, then let the implicit build take care of everything else. + - name: Generate source + run: dotnet build ./Yafc.I18n.Generator/ + # Execute all unit tests in the solution - name: Execute unit tests run: dotnet test diff --git a/.github/workflows/buildcheck-release.yml b/.github/workflows/buildcheck-release.yml index d556704c..6a04df9d 100644 --- a/.github/workflows/buildcheck-release.yml +++ b/.github/workflows/buildcheck-release.yml @@ -34,6 +34,11 @@ jobs: - name: Setup MSBuild.exe uses: microsoft/setup-msbuild@v2 + # For reasons that are unclear, the build fails if the generated files do not exist at the beginning of the build. + # Explicitly build the generator to generate the files, then let the implicit build take care of everything else. + - name: Generate source + run: dotnet build ./Yafc.I18n.Generator/ + # Execute all unit tests in the solution - name: Execute unit tests run: dotnet test diff --git a/.github/workflows/crowdin-translation-action.yml b/.github/workflows/crowdin-translation-action.yml new file mode 100644 index 00000000..1999e30c --- /dev/null +++ b/.github/workflows/crowdin-translation-action.yml @@ -0,0 +1,35 @@ +name: Crowdin Action + +on: + push: + branches: [ internationalization-test ] + +jobs: + synchronize-with-crowdin: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: crowdin action + uses: crowdin/github-action@v2 + with: + upload_sources: true + upload_translations: false + download_translations: true + localization_branch_name: l10n_crowdin_translations + create_pull_request: true + pull_request_title: 'New Crowdin Translations' + pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)' + pull_request_base_branch_name: 'internationalization-test' + skip_untranslated_strings: true + env: + # A classic GitHub Personal Access Token with the 'repo' scope selected (the user should have write access to the repository). + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + + # A numeric ID, found at https://crowdin.com/project//tools/api + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + + # Visit https://crowdin.com/settings#api-key to create this token + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} diff --git a/.gitignore b/.gitignore index b3e98a60..9f505540 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ ## Custom ignores that were not the part of the conventional file Build/ +*.g.cs # Debug launch configuration Y[Aa][Ff][Cc]/Properties/launchSettings.json diff --git a/Docs/Architecture/Serialization.md b/Docs/Architecture/Serialization.md new file mode 100644 index 00000000..e8803bde --- /dev/null +++ b/Docs/Architecture/Serialization.md @@ -0,0 +1,152 @@ +# Serialization system +The project serialization system is somewhat fragile. +It adapts properly to many changes, but can also fail silently. +To reduce the chances of silent failures, it has some [guardrails in the tests](#guarding-tests), to ensure any relevant changes are intentional and functional. + +The serialization system only deals with public instance properties. +Broadly, there are two kinds of public properties, which we'll call 'writable' and 'non-writable'. +A writable property has a public setter, a constructor parameter with the same name and type, or both. +Any other public property is non-writable. +`[Obsolete]` writable properties may (and usually should) omit the getter. + +All constructor parameters, except for the first parameter when derived from `ModelObject<>`, must have the same name and type as a writable property. + +## Property types +The primary data structures that get serialized and deserialized are types derived from `ModelOject` and concrete (non-abstract) classes with the `[Serializable]` attribute. +These are serialized property-by-property, recursively. +Properties are handled as described here: + +|Property type|Writable|Non-writable| +|-|-|-| +|[`ModelObject` and derived types](#modelobjects-and-serializable-types)|Supported if concrete|Supported| +|[`[Serializable]` classes](#modelobjects-and-serializable-types)|Supported if concrete|Ignored| +|[`ReadOnlyCollection<>` and types that implement `ICollection<>` or `IDictionary<,>`](#collections)|Error|Supported if content is supported| +|[`FactorioOject`, all derived types, and `IObjectWithQuality<>`s](#factorioobjects-and-iobjectwithqualitys)|Supported, even when abstract|Ignored| +|[Native and native-like types](#native-types)|Supported if listed|Ignored| +|Any type, if the property has `[SkipSerialization]`|Ignored|Ignored| +|Other types|Error|Ignored| + +Notes: +* The constructor must initialize serialized collections to a non-`null` value. +The property may be declared to return any type that matches the _Property type_ column above. +* Value types are only supported if they appear in the list of [supported native types](#native-types). +* `[Obsolete]` properties must follow all the same rules, except that they do not need a getter if they are writable. + +### `ModelObject`s and `[Serializable]` types +Each class should have exactly one public constructor. +If the class has multiple public constructors, the serialization system will use the first, for whatever definition of "first" the compiler happens to use.\ +**Exception**: If the class has the `[DeserializeWithNonPublicConstructor]` attribute, it should have exactly one non-public constructor instead. + +The constructor may have any number of parameters, subject to the following limitations: +* The first constructor parameter for a type derived (directly or indirectly) from `ModelObject` does not follow the normal rules. +It must be present, must be of type `T`, and can have any name. +* Each (other) parameter must have the same type and name as one of the class's writable properties. +Parameters may match either directly owned properties or properties inherited from a base class. +* If the parameter has a default value, that value must be `default`. +(Or an equivalent: e.g. `null` for reference types and `Nullable<>`, `0` for numeric types, and/or `new()` for value types without a explicit 0-parameter constructor.) + +Writable properties that are not one of the supported types must have the `[SkipSerialization]` attribute. + +Collection properties (always non-writable) must be initialized by the constructor to a non-`null` value. +The constructor is not required to initialize non-writable `ModelObject` properties. +If it does not, the serialization system will discard non-`null` values encountered in the project file. + +### Collections +Collection values must be stored in non-writable properties, must not be passed to the constructor, and must be initialized to an empty collection. +Unsupported keys or values will cause the property to be silently ignored. +Arrays and other fixed-size collections (of supported values) will cause an error when deserializing, even though they implement `ICollection<>`. + +Keys (for dictionaries) must be: `FactorioObject` or derived from it, `IObjectWithQuality<>`, `string`, `Guid`, or `Type`. + +Values may be of any type that can be stored in a writable property. +Explicitly, values are allowed to contain collections, but may not themselves be collections. + +The serializer has special magic allowing it to modify a `ReadOnlyCollection<>`. +Initialize the `ReadOnlyCollection<>` to `new([])`, and the serializer will populate it correctly. + +### `FactorioObject`s and `IObjectWithQuality<>`s +When deserialized, `FactorioObject`s are set to the corresponding object from `Database.allObjects`. +If that object cannot be found (for example, if the mod defining it was removed), it will be set to `null`. +Properties may return `FactorioObject` or any derived type, even if the type is abstract. + +`IObjectWithQuality<>`s are the same, except that the object is fetched by calling `ObjectWithQuality.Get`. + +### Native types +The supported native and native-like types are `int`, `float`, `bool`, `ulong`, `string`, `Type`, `Guid`, `PageReference`, and `enum`s (if backed by `int`). +The `Nullable<>` versions of these types are also supported, where applicable. + +Any value types not listed above, including `Tuple`, `ValueTuple`, and all custom `struct`s, cannot be serialized/deserialized. + +## Guarding tests +There are some "unit" tests guarding the types that are processed by the serialization system. +These tests inspect things the serialization system is likely to encounter in the wild, and ensure that they comply with the rules given under [Property types](#property-types). +They also check for changes to the serialized data, to ensure that any changes are intentional. + +The failure messages should tell you exactly why the test is unhappy. +In general, the tests failures have the following meanings: +* If [`ModelObjects_AreSerializable`](../../Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs) fails, it thinks you violated one of [the rules](#modelobjects-and-serializable-types) in a type derived from `ModelObject`. +* If [`Serializables_AreSerializable`](../../Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs) fails, it thinks you violated one of [the rules](#modelobjects-and-serializable-types) in a `[Serializable]` type. +* If [`TreeHasNotChanged`](../../Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs) fails, you have added, changed, or removed serialized types or properties. + * Ensure the change was intentional and does not break serialization (e.g. changing between `List<>` and `ReadOnlyCollection<>`) or that you have handled the necessary conversions (e.g. for changing from `FactorioObject` to `List`). +Then update the dictionary initializer to match your changes. +If you made significant changes, you should be able to run `BuildPropertyTree` in the debugger to produce the initializer that the test expects. +* If you intended to add a new property to be serialized, but `TreeHasNotChanged` does not fail, the new property probably fell into one of the ["Ignored" categories](#property-types). +* If you intended to add a new `[Serializable]` type to be serialized, but `TreeHasNotChanged` does not fail, make sure there is a writable property of that type. + +Test failures are usually related to writable properties. +Non-writable properties that return array types will also cause test failures. + +## Handling property type changes +The simplest solution is probably introducing a new property and applying `[Obsolete]` to the old property. +The json deserializer will continue reading the corresponding values from the project file, if present, +but the undo system and the json serializer will ignore the old property. +You may (should?) remove the getter from an obsolete writable property. + +Sometimes obsoleting a property is not reasonable, as was the case for the changes from `FactorioObject` to `ObjectWithQuality<>`. +Depending on the requirements, you can either implement `ICustomJsonDeserializer` (as used by `ObjectWithQuality` in [081e9c0f](https://github.com/shpaass/yafc-ce/tree/081e9c0f6b47e155fbc82763590a70d90a64c83c/Yafc.Model/Data/DataClasses.cs#L819) and earlier), +or create a new `ValueSerializer` (as seen in the current `QualityObjectSerializer` implementation). + +## Adding new supported types +Consider workarounds such as using `List>` instead of `List>`, where +```c# +[Serializable] +public sealed class NestedList : IEnumerable /* do not implement ICollection<> or IList<> */ { + public List List { get; } = []; + public IEnumerator GetEnumerator() => List.GetEnumerator(); +} +``` + +### Writable properties and collection values +If the type is already part of Yafc, you may be able to support it simply by adding `[Serializable]` to the type in question. +If that isn't adequate, implement a new class derived from `ValueSerializer`. +The new type should be generic if the serialized type is generic or a type hierarchy. +Add checks for your newly supported type(s) in `IsValueSerializerSupported` and `CreateValueSerializer`. + +The tests should automatically update themselves for newly supported types in writable properties and collections. + +### Dictionary keys +Find the corresponding `ValueSerializer` implementation, or create a new one as described in the previous section. +Add an override for `GetJsonProperty`, to convert the value to a string. +If `ReadFromJson` does not support reading from a `JsonTokenType.PropertyName`, update it to do so, or override `ReadFromJsonProperty`. +In either case, return the desired object by reading the property name. +Add a check for the newly supported type in `IsKeySerializerSupported`. + +The tests should automatically update themselves for newly supported types in dictionary keys. + +### Non-writable properties +To add support for a new type in a non-writable property, implement a new +```c# +internal sealed class NewReadOnlyPropertySerializer(PropertyInfo property) + where TPropertyType : NewPropertyType[] where TOwner : class + : PropertySerializer(property, PropertyType.Normal, false) +``` +Include `TOtherTypes` if the newly supported type is generic. +Serialize nested objects using `ValueSerializer.Default`, to ensure that newly introduced `ValueSerializer` implementations are supported by your collection. + +If your newly supported type is an interface, add a check for the interface type in `GetInterfaceSerializer`. + +If your newly supported type is a class, find the two calls to `GetInterfaceSerializer`. +In the outer else block, add another check for your newly supported type. + +Changes to non-writable property support may require changes to `SerializationTypeValidation` in the vicinity of the calls to `AssertNoArraysOfSerializableValues`, +and will require changes to `TreeHasNotChanged` in the vicinity of the `typeof(ReadOnlyCollection<>)`, `typeof(IDictionary<,>)` and/or `typeof(ICollection<>)` tests. diff --git a/Docs/CodeStyle.md b/Docs/CodeStyle.md index 789c729d..dd4633cf 100644 --- a/Docs/CodeStyle.md +++ b/Docs/CodeStyle.md @@ -74,22 +74,3 @@ 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. - -# Quality Implementation -Despite several attempts, the implementation of quality levels is unpleasantly twisted. -Here are some guidelines to keep handling of quality levels from causing additional problems: -* **Serialization** - * All serialized values (public properties of `ModelObject`s and `[Serializable]` classes) must be a suitable concrete type. - (That is, `ObjectWithQuality`, not `IObjectWithQuality`) -* **Equality** - * Where `==` operators are expected/used, prefer the concrete type. - The `==` operator will silently revert to reference equality if both sides of are the interface type. - * On the other hand, the `==` operator will fail to compile if the two sides are distinct concrete types. - Use `.As()` to convert one side to the interface type. - * Types that call `Object.Equals`, such as `Dictionary` and `HashSet`, will behave correctly on both the interface and concrete types. - The interface type may be more convenient for things like dictionary keys or hashset values. -* **Conversion** - * There is a conversion from `(T?, Quality)` to `ObjectWithQuality?`, where a null input will return a null result. - If the input is definitely not null, use the constructor instead. - C# prohibits conversions to or from interface types. - * The interface types are covariant; an `IObjectWithQuality` may be used as an `IObjectWithQuality` in the same way that an `Item` may be used as a `Goods`. diff --git a/Docs/LinuxOsxInstall.md b/Docs/LinuxOsxInstall.md index 21a82697..87f88243 100644 --- a/Docs/LinuxOsxInstall.md +++ b/Docs/LinuxOsxInstall.md @@ -1,20 +1,20 @@ ### Arch There is an AUR package for yafc-ce: [`factorio-yafc-ce-git`](https://aur.archlinux.org/packages/factorio-yafc-ce-git) -Once the package is installed, it can be run with `factorio-yafc`. Note that at least dotnet 6 or later is required. +Once the package is installed, it can be run with `factorio-yafc`. Note that dotnet runtime v8 is required. ### Debian-based - Download the latest Yafc-ce release. -- [Install dotnet core (v8.0 or later)](https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian) +- [Install dotnet core (runtime version: v8)](https://learn.microsoft.com/en-us/dotnet/core/install/linux-debian) - Install SDL2: - `sudo apt-get install libsdl2-2.0-0` - `sudo apt-get install libsdl2-image-2.0-0` - `sudo apt-get install libsdl2-ttf-2.0-0` - For reference, have following libraries: SDL2-2.0.so.0, SDL2_ttf-2.0.so.0, SDL2_image-2.0.so.0 -- Make sure you have OpenGL available -- Use the `Yafc` executable to run. +- Make sure you have OpenGL available. +- Use the `Yafc` executable to run (you might need to give it executable permissions: `chmod +x Yafc`). ### OSX -- [Install dotnet core (v8.0 or later)](https://dotnet.microsoft.com/download) +- [Install dotnet core (runtime version: v8)](https://dotnet.microsoft.com/download) - For Arm64 Macs, that's it. You can skip to the final step of launching Yafc. - For Intel Macs, you can skip to the step of getting SDL libraries with `brew`. - If you want to build Lua from source, here's how you can do that: diff --git a/Docs/MoreLanguagesSupport.md b/Docs/MoreLanguagesSupport.md index 9e19b7d5..533ac561 100644 --- a/Docs/MoreLanguagesSupport.md +++ b/Docs/MoreLanguagesSupport.md @@ -1,17 +1,21 @@ # YAFC support for more languages -YAFC language support is experimental. If your language is missing, that is probably because of one of two reasons: +You can ask Yafc to display non-English text from the Welcome screen: +- On the Welcome screen, click the language name (probably "English") next to "In-game objects language:" +- Select your language from the drop-down that appears. +- If your language uses non-European glyphs, it may appear at the bottom of the list. + - To use these languages, Yafc may need to do a one-time download of a suitable font. +Click "Confirm" if Yafc asks permission to download a font. + - If you do not wish to have Yafc automatically download a suitable font, click "Select font" in the drop-down, and select a font file that supports your language. -- It has less than 90% support in official Factorio translation -- It uses non-European glyphs (such as Chinese or Japanese languages) - -You can enable support for your language using this method: -- Navigate to `yafc.config` file located at `%localappdata%\YAFC` (`C:\Users\username\AppData\Local\YAFC`). Open it with the text editor. -- Find `language` section and replace the value with your language code. Here are examples of language codes: - - Chinese (Simplified): `zh-CN` +If your language is supported by Factorio but does not appear in the Welcome screen, you can manually force YAFC to use the strings for your language: +- Navigate to `yafc2.config` file located at `%localappdata%\YAFC` (`C:\Users\username\AppData\Local\YAFC`). Open it with a text editor. +- Find the `language` section and replace the value with your language code. Here are examples of language codes: + - Chinese (Simplified): `zh-CN` - Chinese (Traditional): `zh-TW` - Korean: `ko` - Japanese: `ja` - Hebrew: `he` - - Else: Look into `Factorio/data/base/locale` folder and find folder with your language. -- If your language have non-European glyphs, you also need to replace fonts: `Yafc/Data/Roboto-Light.ttf` and `Roboto-Regular.ttf` with any fonts that support your language glyphs. \ No newline at end of file + - Else: Look into `Factorio/data/base/locale` folder and find the folder with your language. +- If your language uses non-European glyphs, you also need to replace the fonts `Yafc/Data/Roboto-Light.ttf` and `Roboto-Regular.ttf` with fonts that support your language. +You may also use the "Select font" button in the language dropdown on the Welcome screen to change the font. diff --git a/FactorioCalc.sln b/FactorioCalc.sln index 248d1484..efdd586a 100644 --- a/FactorioCalc.sln +++ b/FactorioCalc.sln @@ -23,6 +23,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution exclusion.dic = exclusion.dic EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yafc.I18n", "Yafc.I18n\Yafc.I18n.csproj", "{4FEC38A5-A997-48C9-97F5-87BD12119F44}" + ProjectSection(ProjectDependencies) = postProject + {E8A28A02-99C4-41D3-99E3-E6252BD116B7} = {E8A28A02-99C4-41D3-99E3-E6252BD116B7} + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yafc.I18n.Generator", "Yafc.I18n.Generator\Yafc.I18n.Generator.csproj", "{E8A28A02-99C4-41D3-99E3-E6252BD116B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -53,8 +60,19 @@ Global {66B66728-84F0-4242-B49A-B9D746A3CCA5}.Debug|Any CPU.Build.0 = Debug|Any CPU {66B66728-84F0-4242-B49A-B9D746A3CCA5}.Release|Any CPU.ActiveCfg = Release|Any CPU {66B66728-84F0-4242-B49A-B9D746A3CCA5}.Release|Any CPU.Build.0 = Release|Any CPU + {4FEC38A5-A997-48C9-97F5-87BD12119F44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FEC38A5-A997-48C9-97F5-87BD12119F44}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FEC38A5-A997-48C9-97F5-87BD12119F44}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FEC38A5-A997-48C9-97F5-87BD12119F44}.Release|Any CPU.Build.0 = Release|Any CPU + {E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E8A28A02-99C4-41D3-99E3-E6252BD116B7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {643684AA-6CBA-45BE-A603-BDA3020298A9} + EndGlobalSection EndGlobal diff --git a/Yafc.I18n.Generator/SourceGenerator.cs b/Yafc.I18n.Generator/SourceGenerator.cs new file mode 100644 index 00000000..ad7d2eac --- /dev/null +++ b/Yafc.I18n.Generator/SourceGenerator.cs @@ -0,0 +1,173 @@ +using System.Text.RegularExpressions; + +namespace Yafc.I18n.Generator; + +internal partial class SourceGenerator { + private static readonly Dictionary localizationKeys = []; + + private static void Main() { + // Find the solution root directory + string rootDirectory = Environment.CurrentDirectory; + while (!Directory.Exists(Path.Combine(rootDirectory, ".git"))) { + rootDirectory = Path.GetDirectoryName(rootDirectory)!; + } + Environment.CurrentDirectory = rootDirectory; + + Console.WriteLine("Found root directory: " + rootDirectory); + + HashSet keys = []; + HashSet referencedKeys = []; + + using MemoryStream classesMemory = new(), stringsMemory = new(); + using (StreamWriter classes = new(classesMemory, leaveOpen: true), strings = new(stringsMemory, leaveOpen: true)) { + + // Always generate the LocalizableString and LocalizableString0 classes + classes.WriteLine(""" + using System.Diagnostics.CodeAnalysis; + + namespace Yafc.I18n; + + #nullable enable + + /// + /// The base class for YAFC's localizable UI strings. + /// + public abstract class LocalizableString { + private protected readonly string key; + + private protected LocalizableString(string key) => this.key = key; + + /// + /// Localize this string using an arbitrary number of parameters. Insufficient parameters will cause the localization to fail, + /// and excess parameters will be ignored. + /// + /// An array of parameter values. + /// The localized string + public string Localize(params object[] args) => LocalisedStringParser.ParseKey(key, args) ?? "Key not found: " + key; + } + + /// + /// A localizable UI string that needs 0 parameters for localization. + /// These strings will implicitly localize when appropriate. + /// + public sealed class LocalizableString0 : LocalizableString { + internal LocalizableString0(string key) : base(key) { } + + /// + /// Localize this string. + /// + /// The localized string + public string L() => LocalisedStringParser.ParseKey(key, []) ?? "Key not found: " + key; + + /// + /// Implicitly localizes a zero-parameter localizable string. + /// + /// The zero-parameter string to be localized + [return: NotNullIfNotNull(nameof(lString))] + public static implicit operator string?(LocalizableString0? lString) => lString?.L(); + } + """); + + HashSet declaredArities = [0]; + + // Generate the beginning of the LSs class + strings.WriteLine(""" + namespace Yafc.I18n; + + /// + /// A class containing localizable strings for each key defined in the English localization file. This name should be read as + /// LocalizableStrings. It is aggressively abbreviated to help keep lines at a reasonable length. + /// + /// This class is auto-generated. To add new localizable strings, add them to Yafc/Data/locale/en/yafc.cfg + /// and build the solution. + public static class LSs { + """); + + // For each key in locale/en/*.* + foreach (string file in Directory.EnumerateFiles(Path.Combine(rootDirectory, "Yafc/Data/locale/en"))) { + using Stream stream = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + foreach (var (category, key, v) in FactorioLocalization.Read(stream)) { + string value = v; // iteration variables are read-only; make value writable. + int parameterCount = 0; + foreach (Match match in FindParameters().Matches(value)) { + parameterCount = Math.Max(parameterCount, int.Parse(match.Groups[1].Value)); + } + + // If we haven't generated it yet, generate the LocalizableString class + if (declaredArities.Add(parameterCount)) { + classes.WriteLine($$""" + + /// + /// A localizable string that needs {{parameterCount}} parameters for localization. + /// + public sealed class LocalizableString{{parameterCount}} : LocalizableString { + internal LocalizableString{{parameterCount}}(string key) : base(key) { } + + /// + /// Localize this string. + /// + {{string.Join(Environment.NewLine, Enumerable.Range(1, parameterCount).Select(n => $" /// The value to use for parameter __{n}__ when localizing this string."))}} + /// The localized string + public string L({{string.Join(", ", Enumerable.Range(1, parameterCount).Select(n => $"object p{n}"))}}) + => LocalisedStringParser.ParseKey(key, [{{string.Join(", ", Enumerable.Range(1, parameterCount).Select(n => $"p{n}"))}}]) ?? "Key not found: " + key; + } + """); + } + + string pascalCasedKey = string.Join("", key.Split('-').Select(s => char.ToUpperInvariant(s[0]) + s[1..])); + keys.Add(key); + + foreach (Match match in FindReferencedKeys().Matches(value)) { + referencedKeys.Add(match.Groups[1].Value); + } + + if (value.Length > 70) { + value = value[..70] + "..."; + } + value = value.Replace("&", "&").Replace("<", "<"); + + // Generate the read-only PascalCasedKeyName field. + strings.WriteLine($$""" + /// + /// Gets a string that will localize to a value resembling "{{value}}" + /// + """); +#if DEBUG + strings.WriteLine($" public static LocalizableString{parameterCount} {pascalCasedKey} {{ get; }} = new(\"{category}.{key}\");"); +#else + // readonly fields are much smaller than read-only properties, but VS doesn't provide inline reference counts for them. + strings.WriteLine($" public static readonly LocalizableString{parameterCount} {pascalCasedKey} = new(\"{category}.{key}\");"); +#endif + } + } + + foreach (string? undefinedKey in referencedKeys.Except(keys)) { + strings.WriteLine($"#error Found a reference to __YAFC__{undefinedKey}__, which is not defined."); + } + // end of class LLs + strings.WriteLine("}"); + } + + Console.WriteLine($"Loaded {keys.Count} strings."); + + ReplaceIfChanged("Yafc.I18n/LocalizableStringClasses.g.cs", classesMemory); + ReplaceIfChanged("Yafc.I18n/LocalizableStrings.g.cs", stringsMemory); + } + + // Replace the files only if the new content is different than the old content. + private static void ReplaceIfChanged(string filePath, MemoryStream newContent) { + newContent.Position = 0; + if (!File.Exists(filePath) || File.ReadAllText(filePath) != new StreamReader(newContent, leaveOpen: true).ReadToEnd()) { + File.WriteAllBytes(filePath, newContent.ToArray()); + Console.WriteLine($"Updated {Path.GetFullPath(filePath)}."); + } + else { + Console.WriteLine($"{Path.GetFullPath(filePath)} is up-to-date."); + } + } + + [GeneratedRegex("__(\\d+)__")] + private static partial Regex FindParameters(); + [GeneratedRegex("__YAFC__([a-zA-Z0-9_-]+)__")] + private static partial Regex FindReferencedKeys(); +} diff --git a/Yafc.I18n.Generator/Yafc.I18n.Generator.csproj b/Yafc.I18n.Generator/Yafc.I18n.Generator.csproj new file mode 100644 index 00000000..f33527f8 --- /dev/null +++ b/Yafc.I18n.Generator/Yafc.I18n.Generator.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + + + + + diff --git a/Yafc.Parser/FactorioLocalization.cs b/Yafc.I18n/FactorioLocalization.cs similarity index 65% rename from Yafc.Parser/FactorioLocalization.cs rename to Yafc.I18n/FactorioLocalization.cs index 5df039ae..3424c756 100644 --- a/Yafc.Parser/FactorioLocalization.cs +++ b/Yafc.I18n/FactorioLocalization.cs @@ -1,12 +1,18 @@ -using System.Collections.Generic; -using System.IO; +using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("Yafc.Model.Tests")] -namespace Yafc.Parser; +namespace Yafc.I18n; -internal static class FactorioLocalization { +public static class FactorioLocalization { private static readonly Dictionary keys = []; public static void Parse(Stream stream) { + foreach (var (category, key, value) in Read(stream)) { + keys[$"{category}.{key}"] = CleanupTags(value); + } + } + + public static IEnumerable<(string, string, string)> Read(Stream stream) { using StreamReader reader = new StreamReader(stream); string category = ""; @@ -14,13 +20,15 @@ public static void Parse(Stream stream) { string? line = reader.ReadLine(); if (line == null) { - return; + break; } - line = line.Trim(); + // Trim spaces before keys and all spaces around [categories], but not trailing spaces in values. + line = line.TrimStart(); + string trimmed = line.TrimEnd(); - if (line.StartsWith('[') && line.EndsWith(']')) { - category = line[1..^1]; + if (trimmed.StartsWith('[') && trimmed.EndsWith(']')) { + category = trimmed[1..^1]; } else { int idx = line.IndexOf('='); @@ -31,9 +39,8 @@ public static void Parse(Stream stream) { string key = line[..idx]; string val = line[(idx + 1)..]; - keys[category + "." + key] = CleanupTags(val); + yield return (category, key, val); } - } } @@ -69,7 +76,7 @@ private static string CleanupTags(string source) { return null; } - public static void Initialize(Dictionary newKeys) { + internal static void Initialize(Dictionary newKeys) { keys.Clear(); foreach (var (key, value) in newKeys) { keys[key] = value; diff --git a/Yafc.I18n/ILocalizable.cs b/Yafc.I18n/ILocalizable.cs new file mode 100644 index 00000000..d5eed31e --- /dev/null +++ b/Yafc.I18n/ILocalizable.cs @@ -0,0 +1,7 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Yafc.I18n; + +public interface ILocalizable { + bool Get([NotNullWhen(true)] out string? key, [NotNullWhen(true)] out object[]? parameters); +} diff --git a/Yafc.Parser/Data/LocalisedStringParser.cs b/Yafc.I18n/LocalisedStringParser.cs similarity index 84% rename from Yafc.Parser/Data/LocalisedStringParser.cs rename to Yafc.I18n/LocalisedStringParser.cs index d44f4e64..5d1bc9b6 100644 --- a/Yafc.Parser/Data/LocalisedStringParser.cs +++ b/Yafc.I18n/LocalisedStringParser.cs @@ -1,10 +1,9 @@ -using System; -using System.Linq; -using System.Text; +using System.Text; -namespace Yafc.Parser; -internal static class LocalisedStringParser { - public static string? Parse(object localisedString) { +namespace Yafc.I18n; + +public static class LocalisedStringParser { + public static string? ParseObject(object localisedString) { try { return RemoveRichText(ParseStringOrArray(localisedString)); } @@ -13,32 +12,34 @@ internal static class LocalisedStringParser { } } - public static string? Parse(string key, object[] parameters) { + /// + /// Creates the localized string for the supplied key, using for substitutions. + /// + /// The UI key to load. + /// The substitution parameters to be used. + /// The localized string for , using for substitutions. + public static string? ParseKey(string key, object[] parameters) { try { - return RemoveRichText(ParseKey(key, parameters)); + return RemoveRichText(ParseKeyInternal(key, parameters)); } catch { return null; } } - private static string? ParseStringOrArray(object obj) { - if (obj is string str) { - return str; - } - - if (obj is LuaTable table && table.Get(1, out string? key)) { - return ParseKey(key, table.ArrayElements.Skip(1).ToArray()!); + private static string? ParseStringOrArray(object? obj) { + if (obj is ILocalizable table && table.Get(out string? key, out object[]? parameters)) { + return ParseKeyInternal(key, parameters); } - return null; + return obj?.ToString(); } - private static string? ParseKey(string key, object[] parameters) { + private static string? ParseKeyInternal(string key, object?[] parameters) { if (key == "") { StringBuilder builder = new StringBuilder(); - foreach (object subString in parameters) { + foreach (object? subString in parameters) { string? localisedSubString = ParseStringOrArray(subString); if (localisedSubString == null) { return null; @@ -50,7 +51,7 @@ internal static class LocalisedStringParser { return builder.ToString(); } else if (key == "?") { - foreach (object alternative in parameters) { + foreach (object? alternative in parameters) { string? localisedAlternative = ParseStringOrArray(alternative); if (localisedAlternative != null) { return localisedAlternative; @@ -116,7 +117,11 @@ internal static class LocalisedStringParser { case "TILE": case "FLUID": string name = readExtraParameter(); - result.Append(ParseKey($"{type.ToLower()}-name.{name}", [])); + result.Append(ParseKeyInternal($"{type.ToLower()}-name.{name}", [])); + break; + case "YAFC": + name = readExtraParameter(); + result.Append(ParseKeyInternal("yafc." + name, parameters)); break; case "plural_for_parameter": string deciderIdx = readExtraParameter(); diff --git a/Yafc.I18n/Yafc.I18n.csproj b/Yafc.I18n/Yafc.I18n.csproj new file mode 100644 index 00000000..fa71b7ae --- /dev/null +++ b/Yafc.I18n/Yafc.I18n.csproj @@ -0,0 +1,9 @@ + + + + net8.0 + enable + enable + + + diff --git a/Yafc.Model.Tests/Data/LocalisedStringParserTests.cs b/Yafc.Model.Tests/Data/LocalisedStringParserTests.cs index c1312057..45f0b811 100644 --- a/Yafc.Model.Tests/Data/LocalisedStringParserTests.cs +++ b/Yafc.Model.Tests/Data/LocalisedStringParserTests.cs @@ -1,5 +1,5 @@ using Xunit; -using Yafc.Parser; +using Yafc.I18n; namespace Yafc.Model.Data.Tests; @@ -16,49 +16,49 @@ public LocalisedStringParserTests() => FactorioLocalization.Initialize(new Syste [Fact] public void Parse_JustString() { - string localised = LocalisedStringParser.Parse("test"); + string localised = LocalisedStringParser.ParseObject("test"); Assert.Equal("test", localised); } [Fact] public void Parse_RemoveRichText() { - string localised = LocalisedStringParser.Parse("[color=#ffffff]iron[/color] [color=1,0,0]plate[.color] [item=iron-plate]"); + string localised = LocalisedStringParser.ParseObject("[color=#ffffff]iron[/color] [color=1,0,0]plate[.color] [item=iron-plate]"); Assert.Equal("iron plate ", localised); } [Fact] public void Parse_NoParameters() { - string localised = LocalisedStringParser.Parse("not-enough-ingredients", []); + string localised = LocalisedStringParser.ParseKey("not-enough-ingredients", []); Assert.Equal("Not enough ingredients.", localised); } [Fact] public void Parse_Parameter() { - string localised = LocalisedStringParser.Parse("si-unit-kilometer-per-hour", ["100"]); + string localised = LocalisedStringParser.ParseKey("si-unit-kilometer-per-hour", ["100"]); Assert.Equal("100 km/h", localised); } [Fact] public void Parse_LinkItem() { - string localised = LocalisedStringParser.Parse("item-name.big-iron-plate", []); + string localised = LocalisedStringParser.ParseKey("item-name.big-iron-plate", []); Assert.Equal("Big Iron plate", localised); } [Fact] public void Parse_PluralSpecial() { - string localised = LocalisedStringParser.Parse("hours", ["1"]); + string localised = LocalisedStringParser.ParseKey("hours", ["1"]); Assert.Equal("1 hour", localised); } [Fact] public void Parse_PluralRest() { - string localised = LocalisedStringParser.Parse("hours", ["2"]); + string localised = LocalisedStringParser.ParseKey("hours", ["2"]); Assert.Equal("2 hours", localised); } [Fact] public void Parse_PluralWithParameter() { - string localised = LocalisedStringParser.Parse("connecting", ["1"]); + string localised = LocalisedStringParser.ParseKey("connecting", ["1"]); Assert.Equal("1 player is connecting", localised); } @@ -67,7 +67,7 @@ public void Parse_PluralWithParameter() { [InlineData(22, "option 2")] [InlineData(5, "option 3")] public void Parse_PluralEndsIn(int n, string expectedResult) { - string localised = LocalisedStringParser.Parse("ends.in", [n.ToString()]); + string localised = LocalisedStringParser.ParseKey("ends.in", [n.ToString()]); Assert.Equal(expectedResult, localised); } } diff --git a/Yafc.Model.Tests/Model/ProductionTableContentTests.cs b/Yafc.Model.Tests/Model/ProductionTableContentTests.cs index f19486fe..1f07e99d 100644 --- a/Yafc.Model.Tests/Model/ProductionTableContentTests.cs +++ b/Yafc.Model.Tests/Model/ProductionTableContentTests.cs @@ -15,11 +15,11 @@ public void ChangeFuelEntityModules_ShouldPreserveFixedAmount() { ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); - table.modules.beacon = new(Database.allBeacons.Single(), Quality.Normal); - table.modules.beaconModule = new(Database.allModules.Single(m => m.name == "speed-module"), Quality.Normal); + table.modules.beacon = Database.allBeacons.Single().With(Quality.Normal); + table.modules.beaconModule = Database.allModules.Single(m => m.name == "speed-module").With(Quality.Normal); table.modules.beaconsPerBuilding = 2; table.modules.autoFillPayback = MathF.Sqrt(float.MaxValue); @@ -29,16 +29,16 @@ public void ChangeFuelEntityModules_ShouldPreserveFixedAmount() { // assert will ensure the currently fixed value has not changed by more than 0.01%. static void testCombinations(RecipeRow row, ProductionTable table, Action assert) { foreach (EntityCrafter crafter in Database.allCrafters) { - row.entity = new(crafter, Quality.Normal); + row.entity = crafter.With(Quality.Normal); foreach (Goods fuel in crafter.energy.fuels) { - row.fuel = (fuel, Quality.Normal); + row.fuel = fuel.With(Quality.Normal); foreach (Module module in Database.allModules.Concat([null])) { ModuleTemplateBuilder builder = new(); if (module != null) { - builder.list.Add((new(module, Quality.Normal), 0)); + builder.list.Add((module.With(Quality.Normal), 0)); } row.modules = builder.Build(row); @@ -59,7 +59,7 @@ public void ChangeProductionTableModuleConfig_ShouldPreserveFixedAmount() { ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); List modules = [.. Database.allModules.Where(m => !m.name.Contains("productivity"))]; @@ -71,10 +71,10 @@ public void ChangeProductionTableModuleConfig_ShouldPreserveFixedAmount() { // Call assert for each combination. assert will ensure the currently fixed value has not changed by more than 0.01%. void testCombinations(RecipeRow row, ProductionTable table, Action assert) { foreach (EntityCrafter crafter in Database.allCrafters) { - row.entity = new(crafter, Quality.Normal); + row.entity = crafter.With(Quality.Normal); foreach (Goods fuel in crafter.energy.fuels) { - row.fuel = (fuel, Quality.Normal); + row.fuel = fuel.With(Quality.Normal); foreach (Module module in modules) { for (int beaconCount = 0; beaconCount < 13; beaconCount++) { @@ -83,14 +83,14 @@ void testCombinations(RecipeRow row, ProductionTable table, Action assert) { // Preemptive 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) { - beacon = new(beacon, Quality.Normal), - beaconModule = new(module, Quality.Normal), + beacon = beacon.With(Quality.Normal), + beaconModule = module.With(Quality.Normal), beaconsPerBuilding = beaconCount, }]); } else { - table.modules.beacon = new(beacon, Quality.Normal); - table.modules.beaconModule = new(module, Quality.Normal); + table.modules.beacon = beacon.With(Quality.Normal); + table.modules.beaconModule = module.With(Quality.Normal); table.modules.beaconsPerBuilding = beaconCount; } table.Solve((ProjectPage)table.owner).Wait(); @@ -117,17 +117,17 @@ public async Task AllCombinationsWithVariousFixedCounts_DisplayedProductsMatchSc int testCount = 0; // Run through all combinations of recipe, crafter, fuel, and fixed module, including all qualities. - foreach (ObjectWithQuality recipe in Database.recipes.all.WithAllQualities()) { + foreach (IObjectWithQuality recipe in Database.recipes.all.WithAllQualities()) { table.AddRecipe(recipe, DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Last(); - foreach (ObjectWithQuality crafter in Database.allCrafters.WithAllQualities()) { + foreach (IObjectWithQuality crafter in Database.allCrafters.WithAllQualities()) { row.entity = crafter; - foreach (ObjectWithQuality fuel in crafter.target.energy.fuels.WithAllQualities()) { + foreach (IObjectWithQuality fuel in crafter.target.energy.fuels.WithAllQualities()) { row.fuel = fuel; - foreach (ObjectWithQuality module in Database.allModules.WithAllQualities().Prepend(null)) { + foreach (IObjectWithQuality module in Database.allModules.WithAllQualities().Prepend(null)) { row.modules = module == null ? null : new ModuleTemplateBuilder { list = { (module, 0) } }.Build(row); do { // r.NextDouble could (at least in theory) return 0 or a value that rounds to 0. @@ -158,7 +158,7 @@ public async Task AllCombinationsWithVariousFixedCounts_DisplayedProductsMatchSc // ProductsForSolver doesn't include the spent fuel. Append an entry for the spent fuel, in the case that the spent // fuel is not a recipe product. // If the spent fuel is also a recipe product, this value will ignored in favor of the recipe-product value. - .Append(new(row.fuel.FuelResult()?.target.With(row.fuel.FuelResult().quality), 0, null, 0, null)))) { + .Append(new(row.fuel.FuelResult(), 0, null, 0, null)))) { var (solverGoods, solverAmount, _, _, _) = solver; var (displayGoods, displayAmount, _, _) = display; diff --git a/Yafc.Model.Tests/Model/ProductionTableTests.cs b/Yafc.Model.Tests/Model/ProductionTableTests.cs index fce661b7..a4926a91 100644 --- a/Yafc.Model.Tests/Model/ProductionTableTests.cs +++ b/Yafc.Model.Tests/Model/ProductionTableTests.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using System.Reflection; using Xunit; @@ -7,22 +8,6 @@ namespace Yafc.Model.Tests.Model; [Collection("LuaDependentTests")] public class ProductionTableTests { - [Fact] - public void ProductionTableTest_CanSaveAndLoadWithEmptyPage() { - Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); - - ProjectPage page = new(project, typeof(ProductionTable)); - project.pages.Add(page); - - ErrorCollector collector = new(); - using MemoryStream stream = new(); - project.Save(stream); - Project newProject = Project.Read(stream.ToArray(), collector); - - Assert.Equal(ErrorSeverity.None, collector.severity); - Assert.Equal(project.pages.Select(s => s.guid), newProject.pages.Select(p => p.guid)); - } - [Fact] public void ProductionTableTest_CanSaveAndLoadWithRecipe() { Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); @@ -30,7 +15,7 @@ public void ProductionTableTest_CanSaveAndLoadWithRecipe() { ProjectPage page = new(project, typeof(ProductionTable)); project.pages.Add(page); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); ErrorCollector collector = new(); using MemoryStream stream = new(); @@ -48,7 +33,7 @@ public void ProductionTableTest_CanSaveAndLoadWithEmptySubtable() { ProjectPage page = new(project, typeof(ProductionTable)); project.pages.Add(page); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); row.subgroup = new ProductionTable(row); @@ -68,10 +53,10 @@ public void ProductionTableTest_CanSaveAndLoadWithNonemptySubtable() { ProjectPage page = new(project, typeof(ProductionTable)); project.pages.Add(page); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); row.subgroup = new ProductionTable(row); - row.subgroup.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); + row.subgroup.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); ErrorCollector collector = new(); using MemoryStream stream = new(); @@ -83,26 +68,36 @@ public void ProductionTableTest_CanSaveAndLoadWithNonemptySubtable() { } [Fact] - public void ProductionTableTest_CanSaveAndLoadWithProductionSummary() { + public void ProductionTableTest_CanLoadWithUnexpectedObject() { Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); - ProjectPage page = new(project, typeof(ProductionSummary)); + ProjectPage page = new(project, typeof(ProductionTable)); project.pages.Add(page); + ProductionTable table = (ProductionTable)page.content; + table.AddRecipe(Database.recipes.all.Single(r => r.name == "recipe").With(Quality.Normal), DataUtils.DeterministicComparer); + RecipeRow row = table.GetAllRecipes().Single(); + row.subgroup = new ProductionTable(row); + + // Force the subgroup to have a value in modules, which is not present in a normal project. + typeof(ProductionTable).GetFields(BindingFlags.NonPublic | BindingFlags.Instance) + .Single(f => f.FieldType == typeof(ModuleFillerParameters)) + .SetValue(row.subgroup, new ModuleFillerParameters(row.subgroup)); ErrorCollector collector = new(); using MemoryStream stream = new(); project.Save(stream); - Project newProject = Project.Read(stream.ToArray(), collector); + Project newProject = Project.Read(stream.ToArray(), collector); // This reader is expected to skip the unexpected value with a MinorDataLoss warning. - Assert.Equal(ErrorSeverity.None, collector.severity); + Assert.Equal(ErrorSeverity.MinorDataLoss, collector.severity); Assert.Equal(project.pages.Select(s => s.guid), newProject.pages.Select(p => p.guid)); } - [Fact] - public void ProductionTableTest_CanSaveAndLoadWithSummary() { + [Theory] + [MemberData(nameof(ProjectPageContentTypes))] + public void ProductionTableTest_CanSaveAndLoadWithEachPageContentType(Type contentType) { Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); - ProjectPage page = new(project, typeof(Summary)); + ProjectPage page = new(project, contentType); project.pages.Add(page); ErrorCollector collector = new(); @@ -114,28 +109,6 @@ public void ProductionTableTest_CanSaveAndLoadWithSummary() { Assert.Equal(project.pages.Select(s => s.guid), newProject.pages.Select(p => p.guid)); } - [Fact] - public void ProductionTableTest_CanLoadWithUnexpectedObject() { - Project project = LuaDependentTestHelper.GetProjectForLua("Yafc.Model.Tests.Model.ProductionTableContentTests.lua"); - - ProjectPage page = new(project, typeof(ProductionTable)); - project.pages.Add(page); - ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "recipe"), Quality.Normal), DataUtils.DeterministicComparer); - RecipeRow row = table.GetAllRecipes().Single(); - row.subgroup = new ProductionTable(row); - - // Force the subgroup to have a value in modules, which is not present in a normal project. - typeof(ProductionTable).GetFields(BindingFlags.NonPublic | BindingFlags.Instance) - .Single(f => f.FieldType == typeof(ModuleFillerParameters)) - .SetValue(row.subgroup, new ModuleFillerParameters(row.subgroup)); - - ErrorCollector collector = new(); - using MemoryStream stream = new(); - project.Save(stream); - Project newProject = Project.Read(stream.ToArray(), collector); // This reader is expected to skip the unexpected value with a MinorDataLoss warning. - - Assert.Equal(ErrorSeverity.MinorDataLoss, collector.severity); - Assert.Equal(project.pages.Select(s => s.guid), newProject.pages.Select(p => p.guid)); - } + public static TheoryData ProjectPageContentTypes => [.. AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) + .Where(t => typeof(ProjectPageContents).IsAssignableFrom(t) && !t.IsAbstract)]; } diff --git a/Yafc.Model.Tests/Model/ProjectTests.cs b/Yafc.Model.Tests/Model/ProjectTests.cs new file mode 100644 index 00000000..91ca77a0 --- /dev/null +++ b/Yafc.Model.Tests/Model/ProjectTests.cs @@ -0,0 +1,26 @@ +using System; +using Xunit; + +namespace Yafc.Model.Tests; + +public class ProjectTests { + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadFromFile_CanLoadWithEmptyString(bool useMostRecent) + => Assert.NotNull(Project.ReadFromFile("", new(), useMostRecent)); + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ReadFromFile_CanLoadNonexistentFile(bool useMostRecent) + // Assuming that there are no files named .yafc in the current directory. + => Assert.NotNull(Project.ReadFromFile(Guid.NewGuid().ToString() + ".yafc", new(), useMostRecent)); + + [Fact] + // PerformAutoSave is expected to be a no-op in this case. + // This test may need more care and feeding if autosaving is added for nameless projects. + public void PerformAutoSave_NoThrowWhenLoadedWithEmptyString() + // No Assert in this test; the test passes if PerformAutoSave does not throw. + => Project.ReadFromFile("", new(), false).PerformAutoSave(); +} diff --git a/Yafc.Model.Tests/Model/RecipeParametersTests.cs b/Yafc.Model.Tests/Model/RecipeParametersTests.cs index d3359ae7..6ba3e32b 100644 --- a/Yafc.Model.Tests/Model/RecipeParametersTests.cs +++ b/Yafc.Model.Tests/Model/RecipeParametersTests.cs @@ -14,8 +14,8 @@ public async Task FluidBoilingRecipes_HaveCorrectConsumption() { ProjectPage page = new(project, typeof(ProductionTable)); project.pages.Add(page); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe(new(Database.recipes.all.Single(r => r.name == "boiler.boiler.steam"), Quality.Normal), DataUtils.DeterministicComparer); - table.AddRecipe(new(Database.recipes.all.Single(r => r.name == "boiler.heat-exchanger.steam"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "boiler.boiler.steam").With(Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "boiler.heat-exchanger.steam").With(Quality.Normal), DataUtils.DeterministicComparer); List water = Database.fluidVariants["Fluid.water"]; diff --git a/Yafc.Model.Tests/Model/SelectableVariantsTests.cs b/Yafc.Model.Tests/Model/SelectableVariantsTests.cs index 7ad70a00..603ea504 100644 --- a/Yafc.Model.Tests/Model/SelectableVariantsTests.cs +++ b/Yafc.Model.Tests/Model/SelectableVariantsTests.cs @@ -12,7 +12,7 @@ public async Task CanSelectVariantFuel_VariantFuelChanges() { ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe(new(Database.recipes.all.Single(r => r.name == "generator.electricity"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "generator.electricity").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); // Solve is not necessary in this test, but I'm calling it in case we decide to hide the fuel on disabled recipes. @@ -31,7 +31,7 @@ public async Task CanSelectVariantFuelWithFavorites_VariantFuelChanges() { ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe(new(Database.recipes.all.Single(r => r.name == "generator.electricity"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "generator.electricity").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); // Solve is not necessary in this test, but I'm calling it in case we decide to hide the fuel on disabled recipes. @@ -49,7 +49,7 @@ public async Task CanSelectVariantIngredient_VariantIngredientChanges() { ProjectPage page = new ProjectPage(project, typeof(ProductionTable)); ProductionTable table = (ProductionTable)page.content; - table.AddRecipe((Database.recipes.all.Single(r => r.name == "steam_void"), Quality.Normal), DataUtils.DeterministicComparer); + table.AddRecipe(Database.recipes.all.Single(r => r.name == "steam_void").With(Quality.Normal), DataUtils.DeterministicComparer); RecipeRow row = table.GetAllRecipes().Single(); // Solve is necessary here: Disabled recipes have null ingredients (and products), and Solve is the call that updates hierarchyEnabled. diff --git a/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs b/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs index 6e1d1067..0fdb2d47 100644 --- a/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs +++ b/Yafc.Model.Tests/Serialization/SerializationTreeChangeDetection.cs @@ -18,9 +18,9 @@ public class SerializationTreeChangeDetection { [typeof(ModuleFillerParameters)] = new() { [nameof(ModuleFillerParameters.fillMiners)] = typeof(bool), [nameof(ModuleFillerParameters.autoFillPayback)] = typeof(float), - [nameof(ModuleFillerParameters.fillerModule)] = typeof(ObjectWithQuality), - [nameof(ModuleFillerParameters.beacon)] = typeof(ObjectWithQuality), - [nameof(ModuleFillerParameters.beaconModule)] = typeof(ObjectWithQuality), + [nameof(ModuleFillerParameters.fillerModule)] = typeof(IObjectWithQuality), + [nameof(ModuleFillerParameters.beacon)] = typeof(IObjectWithQuality), + [nameof(ModuleFillerParameters.beaconModule)] = typeof(IObjectWithQuality), [nameof(ModuleFillerParameters.beaconsPerBuilding)] = typeof(int), [nameof(ModuleFillerParameters.overrideCrafterBeacons)] = typeof(OverrideCrafterBeacons), }, @@ -35,7 +35,7 @@ public class SerializationTreeChangeDetection { [nameof(ProductionSummaryEntry.subgroup)] = typeof(ProductionSummaryGroup), }, [typeof(ProductionSummaryColumn)] = new() { - [nameof(ProductionSummaryColumn.goods)] = typeof(ObjectWithQuality), + [nameof(ProductionSummaryColumn.goods)] = typeof(IObjectWithQuality), }, [typeof(ProductionSummary)] = new() { [nameof(ProductionSummary.group)] = typeof(ProductionSummaryGroup), @@ -48,22 +48,22 @@ public class SerializationTreeChangeDetection { [nameof(ProductionTable.modules)] = typeof(ModuleFillerParameters), }, [typeof(RecipeRowCustomModule)] = new() { - [nameof(RecipeRowCustomModule.module)] = typeof(ObjectWithQuality), + [nameof(RecipeRowCustomModule.module)] = typeof(IObjectWithQuality), [nameof(RecipeRowCustomModule.fixedCount)] = typeof(int), }, [typeof(ModuleTemplate)] = new() { - [nameof(ModuleTemplate.beacon)] = typeof(ObjectWithQuality), + [nameof(ModuleTemplate.beacon)] = typeof(IObjectWithQuality), [nameof(ModuleTemplate.list)] = typeof(ReadOnlyCollection), [nameof(ModuleTemplate.beaconList)] = typeof(ReadOnlyCollection), }, [typeof(RecipeRow)] = new() { - [nameof(RecipeRow.recipe)] = typeof(ObjectWithQuality), - [nameof(RecipeRow.entity)] = typeof(ObjectWithQuality), - [nameof(RecipeRow.fuel)] = typeof(ObjectWithQuality), + [nameof(RecipeRow.recipe)] = typeof(IObjectWithQuality), + [nameof(RecipeRow.entity)] = typeof(IObjectWithQuality), + [nameof(RecipeRow.fuel)] = typeof(IObjectWithQuality), [nameof(RecipeRow.fixedBuildings)] = typeof(float), [nameof(RecipeRow.fixedFuel)] = typeof(bool), - [nameof(RecipeRow.fixedIngredient)] = typeof(ObjectWithQuality), - [nameof(RecipeRow.fixedProduct)] = typeof(ObjectWithQuality), + [nameof(RecipeRow.fixedIngredient)] = typeof(IObjectWithQuality), + [nameof(RecipeRow.fixedProduct)] = typeof(IObjectWithQuality), [nameof(RecipeRow.builtBuildings)] = typeof(int?), [nameof(RecipeRow.showTotalIO)] = typeof(bool), [nameof(RecipeRow.enabled)] = typeof(bool), @@ -73,7 +73,7 @@ public class SerializationTreeChangeDetection { [nameof(RecipeRow.variants)] = typeof(HashSet), }, [typeof(ProductionLink)] = new() { - [nameof(ProductionLink.goods)] = typeof(ObjectWithQuality), + [nameof(ProductionLink.goods)] = typeof(IObjectWithQuality), [nameof(ProductionLink.amount)] = typeof(float), [nameof(ProductionLink.algorithm)] = typeof(LinkAlgorithm), }, @@ -131,30 +131,10 @@ public class SerializationTreeChangeDetection { [nameof(AutoPlannerGoal.item)] = typeof(Goods), [nameof(AutoPlannerGoal.amount)] = typeof(float), }, - [typeof(ObjectWithQuality)] = new() { - [nameof(ObjectWithQuality.target)] = typeof(Module), - [nameof(ObjectWithQuality.quality)] = typeof(Quality), - }, - [typeof(ObjectWithQuality)] = new() { - [nameof(ObjectWithQuality.target)] = typeof(EntityBeacon), - [nameof(ObjectWithQuality.quality)] = typeof(Quality), - }, [typeof(BeaconOverrideConfiguration)] = new() { - [nameof(BeaconOverrideConfiguration.beacon)] = typeof(ObjectWithQuality), + [nameof(BeaconOverrideConfiguration.beacon)] = typeof(IObjectWithQuality), [nameof(BeaconOverrideConfiguration.beaconCount)] = typeof(int), - [nameof(BeaconOverrideConfiguration.beaconModule)] = typeof(ObjectWithQuality), - }, - [typeof(ObjectWithQuality)] = new() { - [nameof(ObjectWithQuality.target)] = typeof(Goods), - [nameof(ObjectWithQuality.quality)] = typeof(Quality), - }, - [typeof(ObjectWithQuality)] = new() { - [nameof(ObjectWithQuality.target)] = typeof(RecipeOrTechnology), - [nameof(ObjectWithQuality.quality)] = typeof(Quality), - }, - [typeof(ObjectWithQuality)] = new() { - [nameof(ObjectWithQuality.target)] = typeof(EntityCrafter), - [nameof(ObjectWithQuality.quality)] = typeof(Quality), + [nameof(BeaconOverrideConfiguration.beaconModule)] = typeof(IObjectWithQuality), }, }; @@ -168,6 +148,9 @@ public class SerializationTreeChangeDetection { // Walk all serialized types (starting with all concrete ModelObjects), and check which types are serialized and the types of their // properties. Changes to this list may result in save/load issues, so require an extra step (modifying the above dictionary initializer) // to ensure those changes are intentional. + // + // If this test fails, and the change is unintentional or the next steps are not obvious, consult Docs/Architecture/Serialization.md, + // probably starting in the section 'Guarding Tests' or 'Handling property type changes'. public void TreeHasNotChanged() { while (queue.TryDequeue(out Type type)) { Assert.True(propertyTree.Remove(type, out var expectedProperties), $"Serializing new type {MakeTypeName(type)}. Add `[typeof({MakeTypeName(type)})] = new() {{ /*properties*/ }},` to the propertyTree initializer."); diff --git a/Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs b/Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs index 5f9cbfab..4f0030d1 100644 --- a/Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs +++ b/Yafc.Model.Tests/Serialization/SerializationTypeValidation.cs @@ -9,47 +9,55 @@ namespace Yafc.Model.Serialization.Tests; public class SerializationTypeValidation { - [Fact] + [Theory] + [MemberData(nameof(ModelObjectTypes))] // Ensure that all concrete types derived from ModelObject obey the serialization rules. - public void ModelObjects_AreSerializable() { - var types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) - .Where(t => typeof(ModelObject).IsAssignableFrom(t) && !t.IsAbstract); - - foreach (Type type in types) { - ConstructorInfo constructor = FindConstructor(type); - - PropertyInfo ownerProperty = type.GetProperty("owner"); - if (ownerProperty != null) { - // If derived from ModelObject (as tested by "There's an 'owner' property"), the first constructor parameter must be T. - Assert.True(constructor.GetParameters().Length > 0, $"The first constructor parameter for type {MakeTypeName(type)} should be the parent object."); - Type baseType = typeof(ModelObject<>).MakeGenericType(ownerProperty.PropertyType); - Assert.True(baseType.IsAssignableFrom(type), $"The first constructor parameter for type {MakeTypeName(type)} is not the parent type."); - } - - // Cheating a bit here: Project is the only ModelObject that is not a ModelObject, and its constructor has no parameters. - // So we're just skipping a parameter that doesn't exist. - AssertConstructorParameters(type, constructor.GetParameters().Skip(1)); - AssertSettableProperties(type); - AssertDictionaryKeys(type); + // For details about the serialization rules and how to approach changes and test failures, see Docs/Architecture/Serialization.md. + public void ModelObjects_AreSerializable(Type modelObjectType) { + ConstructorInfo constructor = FindConstructor(modelObjectType); + + PropertyInfo ownerProperty = modelObjectType.GetProperty("owner"); + if (ownerProperty != null) { + // If derived from ModelObject (as tested by "There's an 'owner' property"), the first constructor parameter must be T. + Assert.True(constructor.GetParameters().Length > 0, $"The first constructor parameter for type {MakeTypeName(modelObjectType)} should be the parent object."); + Type baseType = typeof(ModelObject<>).MakeGenericType(ownerProperty.PropertyType); + Assert.True(baseType.IsAssignableFrom(modelObjectType), $"The first constructor parameter for type {MakeTypeName(modelObjectType)} is not the parent type."); } + + // Cheating a bit here: Project is the only ModelObject that is not a ModelObject, and its constructor has no parameters. + // So we're just skipping a parameter that doesn't exist. + AssertConstructorParameters(modelObjectType, constructor.GetParameters().Skip(1)); + AssertSettableProperties(modelObjectType); + AssertDictionaryKeys(modelObjectType); + AssertCollectionValues(modelObjectType); + AssertNoArraysOfSerializableValues(modelObjectType); } - [Fact] + public static TheoryData ModelObjectTypes => [.. AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) + .Where(t => typeof(ModelObject).IsAssignableFrom(t) && !t.IsAbstract)]; + + [Theory] + [MemberData(nameof(SerializableTypes))] // Ensure that all [Serializable] types in the Yafc namespace obey the serialization rules, // except compiler-generated types and those in Yafc.Blueprints. - public void Serializables_AreSerializable() { - var types = AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) - .Where(t => t.GetCustomAttribute() != null && t.FullName.StartsWith("Yafc.") && !t.FullName.StartsWith("Yafc.Blueprints")); - - foreach (Type type in types.Where(type => type.GetCustomAttribute() == null)) { - ConstructorInfo constructor = FindConstructor(type); - - AssertConstructorParameters(type, constructor.GetParameters()); - AssertSettableProperties(type); - AssertDictionaryKeys(type); - } + // For details about the serialization rules and how to approach changes and test failures, see Docs/Architecture/Serialization.md. + public void Serializables_AreSerializable(Type serializableType) { + Assert.False(serializableType.IsAbstract, "[Serializable] types must not be abstract."); + Assert.False(serializableType.IsValueType, "[Serializable] types must not be structs (or record structs)."); + + ConstructorInfo constructor = FindConstructor(serializableType); + + AssertConstructorParameters(serializableType, constructor.GetParameters()); + AssertSettableProperties(serializableType); + AssertDictionaryKeys(serializableType); + AssertCollectionValues(serializableType); + AssertNoArraysOfSerializableValues(serializableType); } + public static TheoryData SerializableTypes => [.. AppDomain.CurrentDomain.GetAssemblies().SelectMany(a => a.GetTypes()) + .Where(t => t.FullName.StartsWith("Yafc.") && !t.FullName.StartsWith("Yafc.Blueprints") + && t.GetCustomAttribute() != null && t.GetCustomAttribute() == null)]; + internal static ConstructorInfo FindConstructor(Type type) { BindingFlags flags = BindingFlags.Instance; if (type.GetCustomAttribute() != null) { @@ -61,7 +69,7 @@ internal static ConstructorInfo FindConstructor(Type type) { // The constructor (public or non-public, depending on the attribute) must exist ConstructorInfo constructor = type.GetConstructors(flags).FirstOrDefault(); - Assert.True(constructor != null, $"Could not find the constructor for type {MakeTypeName(type)}."); + Assert.True(constructor != null, $"Could not find the constructor for type {MakeTypeName(type)}. Consider adding or removing [DeserializeWithNonPublicConstructor]."); return constructor; } @@ -70,47 +78,113 @@ private static void AssertConstructorParameters(Type type, IEnumerable.CreateValueSerializer did not create a serializer."); // and must have a matching property PropertyInfo property = type.GetProperty(parameter.Name); Assert.True(property != null, $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' does not have a matching property."); Assert.True(parameter.ParameterType == property.PropertyType, $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' does not have the same type as its property."); + + if (!parameter.IsOptional) { + Assert.True(property.GetCustomAttribute() == null, + $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' is a required parameter, but matches a [SkipSerialization] property."); + Assert.True(property.GetCustomAttribute() == null, + $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' is a required parameter, but matches an [Obsolete] property."); + } + else if (parameter.ParameterType.IsValueType) { + typeof(SerializationTypeValidation).GetMethod(nameof(CheckDefaultValue), BindingFlags.Static | BindingFlags.NonPublic) + .MakeGenericMethod(parameter.ParameterType) + .Invoke(null, [type, parameter]); + } } } + private static void CheckDefaultValue(Type type, ParameterInfo parameter) { + // parameter.DefaultValue is null when complex structs (e.g. Guids) should be 0-initialized. + T defaultValue = (T)(parameter.DefaultValue ?? default(T)); + Assert.True(Equals(defaultValue, default(T)), + $"Constructor of type {MakeTypeName(type)} parameter '{parameter.Name}' is an optional parameter with a default value ({defaultValue}) that is not default({MakeTypeName(parameter.ParameterType)})."); + } + private static void AssertSettableProperties(Type type) { - foreach (PropertyInfo property in type.GetProperties().Where(p => p.GetSetMethod() != null)) { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.GetSetMethod() != null)) { if (property.GetCustomAttribute() == null) { - // Properties with a public setter must be IsValueSerializerSupported + // Properties with a public setter must be IsValueSerializerSupported. Assert.True(ValueSerializer.IsValueSerializerSupported(property.PropertyType), - $"The type of property {MakeTypeName(type)}.{property.Name} should be a supported value type."); + $"The type of property {MakeTypeName(type)}.{property.Name} should be a type supported for writable properties."); + Assert.True(ValueSerializerExists(property.PropertyType, out _), + $"For the property {MakeTypeName(type)}.{property.Name}, IsValueSerializerSupported claims the type {MakeTypeName(property.PropertyType)} is supported for writable properties (also collection/dictionary values), but ValueSerializer<>.CreateValueSerializer did not create a serializer."); } } } private void AssertDictionaryKeys(Type type) { - foreach (PropertyInfo property in type.GetProperties().Where(p => p.GetSetMethod() == null)) { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.GetSetMethod() == null)) { if (property.GetCustomAttribute() == null) { Type iDictionary = property.PropertyType.GetInterfaces() .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)); if (iDictionary != null) { - if (ValueSerializer.IsValueSerializerSupported(iDictionary.GenericTypeArguments[0]) + if (ValueSerializer.IsKeySerializerSupported(iDictionary.GenericTypeArguments[0]) && ValueSerializer.IsValueSerializerSupported(iDictionary.GenericTypeArguments[1])) { - object serializer = typeof(ValueSerializer<>).MakeGenericType(iDictionary.GenericTypeArguments[0]).GetField("Default").GetValue(null); + Assert.True(ValueSerializerExists(iDictionary.GenericTypeArguments[0], out object serializer), + $"For the property {MakeTypeName(type)}.{property.Name}, IsKeySerializerSupported claims type {MakeTypeName(iDictionary.GenericTypeArguments[0])} is supported for dictionary keys, but ValueSerializer<>.CreateValueSerializer did not create a serializer."); MethodInfo getJsonProperty = serializer.GetType().GetMethod(nameof(ValueSerializer.GetJsonProperty)); // Dictionary keys must be serialized by an overridden ValueSerializer.GetJsonProperty() method. Assert.True(getJsonProperty != getJsonProperty.GetBaseDefinition(), - $"In {MakeTypeName(type)}.{property.Name}, the dictionary keys are of an unsupported type."); + $"In {MakeTypeName(type)}.{property.Name}, the dictionary keys claim to be supported, but {MakeTypeName(serializer.GetType())} does not have an overridden {nameof(ValueSerializer.GetJsonProperty)} method."); + + Assert.True(ValueSerializerExists(iDictionary.GenericTypeArguments[1], out _), + $"For the property {MakeTypeName(type)}.{property.Name}, IsValueSerializerSupported claims the type {MakeTypeName(iDictionary.GenericTypeArguments[1])} is supported for dictionary values (also writable properties), but ValueSerializer<>.CreateValueSerializer did not create a serializer."); } } } } } + private static void AssertCollectionValues(Type type) { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.GetSetMethod() == null)) { + if (property.GetCustomAttribute() == null) { + Type iDictionary = property.PropertyType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IDictionary<,>)); + Type iCollection = property.PropertyType.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICollection<>)); + + if (iDictionary == null && iCollection != null && ValueSerializer.IsKeySerializerSupported(iCollection.GenericTypeArguments[0])) { + Assert.True(ValueSerializerExists(iCollection.GenericTypeArguments[0], out object serializer), + $"For the property {MakeTypeName(type)}.{property.Name}, IsValueSerializerSupported claims the type {MakeTypeName(iCollection.GenericTypeArguments[0])} is supported for collection values (also writable properties), but ValueSerializer<>.CreateValueSerializer did not create a serializer."); + } + } + } + } + + private static void AssertNoArraysOfSerializableValues(Type type) { + foreach (PropertyInfo property in type.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.GetSetMethod() == null)) { + if (property.GetCustomAttribute() == null) { + if (property.PropertyType.IsArray && ValueSerializer.IsValueSerializerSupported(property.PropertyType.GetElementType())) { + // Public properties without a public setter must not be arrays of writable-property values. + // (and properties with a public setter typically can't implement ICollection<>, as checked by AssertSettableProperties.) + Assert.Fail($"The non-writable property {MakeTypeName(type)}.{property.Name} is an array of writable-property values, which is not supported."); + } + } + } + } + + private static bool ValueSerializerExists(Type type, out object serializer) { + try { + serializer = typeof(ValueSerializer<>).MakeGenericType(type).GetField("Default").GetValue(null); + return serializer != null; + } + catch { + serializer = null; + return false; + } + } + internal static string MakeTypeName(Type type) { if (type.IsGenericType) { if (type.GetGenericTypeDefinition() == typeof(Nullable<>)) { diff --git a/Yafc.Model.Tests/TestHelpers.cs b/Yafc.Model.Tests/TestHelpers.cs index 98e8626e..088ad889 100644 --- a/Yafc.Model.Tests/TestHelpers.cs +++ b/Yafc.Model.Tests/TestHelpers.cs @@ -10,6 +10,6 @@ internal static class TestHelpers { /// The type of the input objects. Must be or one of its subclasses. /// The sequence of values that should have qualities applied to them. /// The cartesian product of and the members of . - public static IEnumerable> WithAllQualities(this IEnumerable values) where T : FactorioObject + public static IEnumerable> WithAllQualities(this IEnumerable values) where T : FactorioObject => values.SelectMany(c => Database.qualities.all.Select(q => c.With(q))).Distinct(); } diff --git a/Yafc.Model.Tests/Yafc.Model.Tests.csproj b/Yafc.Model.Tests/Yafc.Model.Tests.csproj index 0abfb0f1..b4b33c84 100644 --- a/Yafc.Model.Tests/Yafc.Model.Tests.csproj +++ b/Yafc.Model.Tests/Yafc.Model.Tests.csproj @@ -14,6 +14,7 @@ + diff --git a/Yafc.Model/Analysis/Analysis.cs b/Yafc.Model/Analysis/Analysis.cs index 21a473ec..03c3904d 100644 --- a/Yafc.Model/Analysis/Analysis.cs +++ b/Yafc.Model/Analysis/Analysis.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Yafc.I18n; namespace Yafc.Model; @@ -16,13 +17,11 @@ public abstract class Analysis { public static void ProcessAnalyses(IProgress<(string, string)> progress, Project project, ErrorCollector errors) { foreach (var analysis in analyses) { - progress.Report(("Running analysis algorithms", analysis.GetType().Name)); + progress.Report((LSs.ProgressRunningAnalysis, analysis.GetType().Name)); analysis.Compute(project, errors); } } - public abstract string description { get; } - public static void Do(Project project) where T : Analysis { foreach (var analysis in analyses) { if (analysis is T t) { diff --git a/Yafc.Model/Analysis/AutomationAnalysis.cs b/Yafc.Model/Analysis/AutomationAnalysis.cs index eda1386b..13aff95f 100644 --- a/Yafc.Model/Analysis/AutomationAnalysis.cs +++ b/Yafc.Model/Analysis/AutomationAnalysis.cs @@ -90,6 +90,4 @@ public override void Compute(Project project, ErrorCollector warnings) { } automatable = state; } - - public override string description => "Automation analysis tries to find what objects can be automated. Object cannot be automated if it requires looting an entity or manual crafting."; } diff --git a/Yafc.Model/Analysis/CostAnalysis.cs b/Yafc.Model/Analysis/CostAnalysis.cs index e7680362..b5e3cb0a 100644 --- a/Yafc.Model/Analysis/CostAnalysis.cs +++ b/Yafc.Model/Analysis/CostAnalysis.cs @@ -5,6 +5,7 @@ using System.Text; using Google.OrTools.LinearSolver; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -51,14 +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 + ": "; + itemAmountPrefix = LSs.CostAnalysisEstimatedAmountFor.L(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: "; + itemAmountPrefix = LSs.CostAnalysisEstimatedAmount; foreach (Technology technology in Database.technologies.all.ExceptExcluded(this)) { if (technology.IsAccessible() && technology.ingredients is not null) { @@ -352,7 +353,7 @@ public override void Compute(Project project, ErrorCollector warnings) { } else { if (!onlyCurrentMilestones) { - warnings.Error("Cost analysis was unable to process this modpack. This may indicate a bug in Yafc.", ErrorSeverity.AnalysisWarning); + warnings.Error(LSs.CostAnalysisFailed, ErrorSeverity.AnalysisWarning); } } @@ -362,49 +363,40 @@ public override void Compute(Project project, ErrorCollector warnings) { 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."; - - private static readonly StringBuilder sb = new StringBuilder(); public static string GetDisplayCost(FactorioObject goods) { float cost = goods.Cost(); float costNow = goods.Cost(true); if (float.IsPositiveInfinity(cost)) { - return "YAFC analysis: Unable to find a way to fully automate this"; + return LSs.AnalysisNotAutomatable; } - _ = sb.Clear(); - float compareCost = cost; float compareCostNow = costNow; - string costPrefix; + string finalCost; if (goods is Fluid) { compareCost = cost * 50; compareCostNow = costNow * 50; - costPrefix = "YAFC cost per 50 units of fluid:"; + finalCost = LSs.CostAnalysisFluidCost.L(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); } else if (goods is Item) { - costPrefix = "YAFC cost per item:"; + finalCost = LSs.CostAnalysisItemCost.L(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); } else if (goods is Special special && special.isPower) { - costPrefix = "YAFC cost per 1 MW:"; + finalCost = LSs.CostAnalysisEnergyCost.L(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); } else if (goods is Recipe) { - costPrefix = "YAFC cost per recipe:"; + finalCost = LSs.CostAnalysisRecipeCost.L(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); } else { - costPrefix = "YAFC cost:"; + finalCost = LSs.CostAnalysisGenericCost.L(DataUtils.FormatAmount(compareCost, UnitOfMeasure.None)); } - _ = 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(')'); + return LSs.CostAnalysisWithCurrentCost.L(finalCost, DataUtils.FormatAmount(compareCostNow, UnitOfMeasure.None)); } - return sb.ToString(); + return finalCost; } public static float GetBuildingHours(Recipe recipe, float flow) => recipe.time * flow * (1000f / 3600f); @@ -415,6 +407,6 @@ public static string GetDisplayCost(FactorioObject goods) { return null; } - return DataUtils.FormatAmount(itemFlow * 1000f, UnitOfMeasure.None, itemAmountPrefix); + return itemAmountPrefix + DataUtils.FormatAmount(itemFlow * 1000f, UnitOfMeasure.None); } } diff --git a/Yafc.Model/Analysis/DependencyNode.cs b/Yafc.Model/Analysis/DependencyNode.cs index b6ab7203..5c6b3fab 100644 --- a/Yafc.Model/Analysis/DependencyNode.cs +++ b/Yafc.Model/Analysis/DependencyNode.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Linq; using System.Numerics; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -68,6 +69,12 @@ private DependencyNode() { } // All derived classes should be nested classes /// internal abstract IEnumerable Flatten(); + /// + /// Gets the number of entries in this dependency tree. + /// + /// + public int Count() => Flatten().Count(); + /// /// Determines whether the object that owns this dependency tree is accessible, based on the accessibility /// returns for dependent objects, and the types of this node and its children. @@ -224,7 +231,7 @@ public override void Draw(ImGui gui, Action, Fl foreach (var dependency in dependencies) { if (!isFirst) { using (gui.EnterGroup(new(1, .25f))) { - gui.BuildText("-- OR --", Font.productionTableHeader); + gui.BuildText(LSs.DependencyOrBar, Font.productionTableHeader); } gui.DrawRectangle(gui.lastRect - offset, SchemeColor.GreyAlt); } diff --git a/Yafc.Model/Analysis/Milestones.cs b/Yafc.Model/Analysis/Milestones.cs index 0c3eaa62..ad0fddf4 100644 --- a/Yafc.Model/Analysis/Milestones.cs +++ b/Yafc.Model/Analysis/Milestones.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -188,16 +189,13 @@ public void ComputeWithParameters(Project project, ErrorCollector warnings, Fact bool hasAutomatableRocketLaunch = result[Database.objectsByTypeName["Special.launch"]] != 0; List milestonesNotReachable = [.. milestones.Except(sortedMilestones)]; 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(LSs.MilestoneAnalysisMostInaccessible, 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); + warnings.Error(LSs.MilestoneAnalysisNoRocketLaunch, ErrorSeverity.AnalysisWarning); } else if (milestonesNotReachable.Count > 0) { - 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(LSs.MilestoneAnalysisInaccessibleMilestones.L(string.Join(LSs.ListSeparator, milestonesNotReachable.Select(x => x.locName))), ErrorSeverity.AnalysisWarning); } logger.Information("Milestones calculation finished in {ElapsedTime}ms.", time.ElapsedMilliseconds); @@ -259,13 +257,4 @@ private static bool[] WalkAccessibilityGraph(Project project, HashSet "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/TechnologyScienceAnalysis.cs b/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs index 6f29bea4..ad899af0 100644 --- a/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs +++ b/Yafc.Model/Analysis/TechnologyScienceAnalysis.cs @@ -108,7 +108,4 @@ 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()); } - - public override string description => - "Technology analysis calculates the total amount of science packs required for each technology"; } diff --git a/Yafc.Model/Blueprints/BlueprintUtilities.cs b/Yafc.Model/Blueprints/BlueprintUtilities.cs index c9f6ecbb..054084bd 100644 --- a/Yafc.Model/Blueprints/BlueprintUtilities.cs +++ b/Yafc.Model/Blueprints/BlueprintUtilities.cs @@ -51,7 +51,7 @@ public static string ExportConstantCombinators(string name, IReadOnlyList<(IObje public static string ExportRequesterChests(string name, IReadOnlyList<(IObjectWithQuality item, int amount)> goods, EntityContainer chest, bool copyToClipboard = true) { if (chest.logisticSlotsCount <= 0) { - throw new NotSupportedException("Chest does not have logistic slots"); + throw new ArgumentException("Chest does not have logistic slots"); } int combinatorCount = ((goods.Count - 1) / chest.logisticSlotsCount) + 1; diff --git a/Yafc.Model/Data/DataClasses.cs b/Yafc.Model/Data/DataClasses.cs index d7ececcd..09b1ca38 100644 --- a/Yafc.Model/Data/DataClasses.cs +++ b/Yafc.Model/Data/DataClasses.cs @@ -1,9 +1,10 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.CompilerServices; -using System.Text.Json; +using Yafc.I18n; using Yafc.UI; [assembly: InternalsVisibleTo("Yafc.Parser")] @@ -47,14 +48,14 @@ public abstract class FactorioObject : IFactorioObjectWrapper, IComparable locName; - public void FallbackLocalization(FactorioObject? other, string description) { + public void FallbackLocalization(FactorioObject? other, LocalizableString1 description) { if (locName == null) { if (other == null) { locName = name; } else { locName = other.locName; - locDescr = description + " " + locName; + locDescr = description.L(locName); } } @@ -183,6 +184,7 @@ public class Recipe : RecipeOrTechnology { public Dictionary technologyProductivity { get; internal set; } = []; public bool preserveProducts { get; internal set; } public bool hidden { get; internal set; } + public float? maximumProductivity { get; internal set; } public bool HasIngredientVariants() { foreach (var ingredient in ingredients) { @@ -209,6 +211,7 @@ protected override List GetDependenciesHelper() { public class Mechanics : Recipe { internal FactorioObject source { get; set; } = null!; // null-forgiving: Set by CreateSpecialRecipe internal override FactorioObjectSortOrder sortingOrder => FactorioObjectSortOrder.Mechanics; + internal LocalizableString localizationKey = null!; // null-forgiving: Set by CreateSpecialRecipe public override string type => "Mechanics"; } @@ -229,11 +232,11 @@ string IFactorioObjectWrapper.text { get { string text = goods.locName; if (amount != 1f) { - text = amount + "x " + text; + text = LSs.IngredientAmount.L(amount, goods.locName); } if (!temperature.IsAny()) { - text += " (" + temperature + ")"; + text = LSs.IngredientAmountWithTemperature.L(amount, goods.locName, temperature); } return text; @@ -312,27 +315,35 @@ 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; + string text; - if (amountMin != amountMax) { - text = DataUtils.FormatAmount(amountMin, UnitOfMeasure.None) + "-" + text; + if (probability == 1) { + if (amountMin == amountMax) { + text = LSs.ProductAmount.L(DataUtils.FormatAmount(amountMax, UnitOfMeasure.None), goods.locName); + } + else { + text = LSs.ProductAmountRange.L(DataUtils.FormatAmount(amountMin, UnitOfMeasure.None), DataUtils.FormatAmount(amountMax, UnitOfMeasure.None), goods.locName); } } - if (probability != 1f) { - text = DataUtils.FormatAmount(probability, UnitOfMeasure.Percent) + " " + text; - } - else if (amountMin == 1 && amountMax == 1) { - text = "1x " + text; + else { + if (amountMin == amountMax) { + if (amountMin == 1) { + text = LSs.ProductProbability.L(DataUtils.FormatAmount(probability, UnitOfMeasure.Percent), goods.locName); + } + else { + text = LSs.ProductProbabilityAmount.L(DataUtils.FormatAmount(probability, UnitOfMeasure.Percent), DataUtils.FormatAmount(amountMax, UnitOfMeasure.None), goods.locName); + } + } + else { + text = LSs.ProductProbabilityAmountRange.L(DataUtils.FormatAmount(probability, UnitOfMeasure.Percent), DataUtils.FormatAmount(amountMin, UnitOfMeasure.None), DataUtils.FormatAmount(amountMax, UnitOfMeasure.None), goods.locName); + } } if (percentSpoiled == 0) { - text += ", always fresh"; + text = LSs.ProductAlwaysFresh.L(text); } else if (percentSpoiled != null) { - text += ", " + DataUtils.FormatAmount(percentSpoiled.Value, UnitOfMeasure.Percent) + " spoiled"; + text = LSs.ProductFixedSpoilage.L(text, DataUtils.FormatAmount(percentSpoiled.Value, UnitOfMeasure.Percent)); } return text; @@ -385,6 +396,7 @@ public Item() { public int stackSize { get; internal set; } public Entity? placeResult { get; internal set; } public Entity? plantResult { get; internal set; } + public int rocketCapacity { get; internal set; } public override bool isPower => false; public override string type => "Item"; internal override FactorioObjectSortOrder sortingOrder => FactorioObjectSortOrder.Items; @@ -661,7 +673,7 @@ public static bool CanAcceptModule(ModuleSpecification module, AllowedEffects ef return true; } - public bool CanAcceptModule(ObjectWithQuality module) where T : Module => CanAcceptModule(module.target.moduleSpecification); + public bool CanAcceptModule(IObjectWithQuality module) where T : Module => CanAcceptModule(module.target.moduleSpecification); public bool CanAcceptModule(ModuleSpecification module) => CanAcceptModule(module, allowedEffects, allowedModuleCategories); } @@ -796,7 +808,9 @@ public override DependencyNode GetDependencies() { /// /// Represents a with an attached modifier. /// -/// The concrete type of the quality-modified object. +/// The type of the quality-modified object. +/// Like s and their derived types, any two values may be compared using +/// == and the default reference equality. public interface IObjectWithQuality : IFactorioObjectWrapper where T : FactorioObject { /// /// Gets the object managed by this instance. @@ -809,70 +823,78 @@ public interface IObjectWithQuality : IFactorioObjectWrapper where T : Fa } /// -/// Represents a with an attached modifier. +/// Provides static methods for interacting with the canonical quality objects. /// -/// The concrete type of the quality-modified object. -/// The object to be associated with a quality modifier. If this parameter might be , -/// use the implicit conversion from instead. -/// The quality for this object. -[Serializable] -public sealed class ObjectWithQuality(T target, Quality quality) : IObjectWithQuality, ICustomJsonDeserializer> where T : FactorioObject { - // These items do not support quality: +/// References to the old ObjectWithQuality<T> class should be changed to , and constructor +/// calls and conversions should be replaced with calls to . +public static class ObjectWithQuality { + private static readonly Dictionary<(FactorioObject, Quality), object> _cache = []; + // These items do not support quality private static readonly HashSet nonQualityItemNames = ["science", "item-total-input", "item-total-output"]; - /// - public T target { get; } = target ?? throw new ArgumentNullException(nameof(target)); - /// - public Quality quality { get; } = CheckQuality(target, quality ?? throw new ArgumentNullException(nameof(quality))); - - private static Quality CheckQuality(T target, Quality quality) => target switch { - // Things that don't support quality: - Fluid or Location or Mechanics { source: Entity } or Quality or Special or Technology or Tile => Quality.Normal, - Recipe r when r.ingredients.All(i => i.goods is Fluid) => Quality.Normal, - // Most other things support quality, but a few items are excluded: - Item when nonQualityItemNames.Contains(target.name) => Quality.Normal, - _ => quality - }; - /// - /// Creates a new with the current and the specified . + /// Gets the representing a with an attached modifier. /// - public ObjectWithQuality With(Quality quality) => new(target, quality); - - string IFactorioObjectWrapper.text => ((IFactorioObjectWrapper)target).text; - FactorioObject IFactorioObjectWrapper.target => target; - float IFactorioObjectWrapper.amount => ((IFactorioObjectWrapper)target).amount; - - public static implicit operator ObjectWithQuality?((T? entity, Quality quality) value) => value.entity == null ? null : new(value.entity, value.quality); + /// The type of the quality-modified object. + /// The object to be associated with a quality modifier. + /// The quality for this object. + /// For shorter expressions/lines, consider calling instead. + [return: NotNullIfNotNull(nameof(target))] + public static IObjectWithQuality? Get(T? target, Quality quality) where T : FactorioObject + => target == null ? null : (IObjectWithQuality)_cache[(target, quality ?? throw new ArgumentNullException(nameof(quality)))]; - /// - public static bool Deserialize(ref Utf8JsonReader reader, DeserializationContext context, out ObjectWithQuality? result) { - if (reader.TokenType == JsonTokenType.String) { - // Read the old `"entity": "Entity.solar-panel"` format. - if (Database.objectsByTypeName[reader.GetString()!] is not T obj) { - context.Error($"Could not convert '{reader.GetString()}' to a {typeof(T).Name}.", ErrorSeverity.MinorDataLoss); - result = null; - return true; + /// + /// Constructs all s for the current contents of data.raw. This should only be called by + /// FactorioDataDeserializer.LoadData. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + internal static void LoadCache(List allObjects) { + _cache.Clear(); + // Quality.Normal must be processed first, so the reset-to-normal logic will work properly. + foreach (var quality in allObjects.OfType().Where(x => x != Quality.Normal).Prepend(Quality.Normal)) { + foreach (var obj in allObjects) { + MakeObject(obj, quality); } - result = new(obj, Quality.Normal); - return true; } - // This is probably the current `"entity": { "target": "Entity.solar-panel", "quality": "Quality.rare" }` format. - result = default; - return false; } - // Ensure ObjectWithQuality equals ObjectWithQuality as long as the properties are equal, regardless of X and Y. - public override bool Equals(object? obj) => Equals(obj as IObjectWithQuality); - public bool Equals(IObjectWithQuality? other) => other is not null && target == other.target && quality == other.quality; - public override int GetHashCode() => HashCode.Combine(target, quality); - - public static bool operator ==(ObjectWithQuality? left, ObjectWithQuality? right) => left == (IObjectWithQuality?)right; - public static bool operator ==(ObjectWithQuality? left, IObjectWithQuality? right) => (left is null && right is null) || (left is not null && left.Equals(right)); - public static bool operator ==(IObjectWithQuality? left, ObjectWithQuality? right) => right == left; - public static bool operator !=(ObjectWithQuality? left, ObjectWithQuality? right) => !(left == right); - public static bool operator !=(ObjectWithQuality? left, IObjectWithQuality? right) => !(left == right); - public static bool operator !=(IObjectWithQuality? left, ObjectWithQuality? right) => !(left == right); + private static void MakeObject(FactorioObject target, Quality quality) { + Quality realQuality = target switch { + // Things that don't support quality: + Fluid or Location or Mechanics { source: Entity } or Quality or Special or Technology or Tile => Quality.Normal, + Recipe r when r.ingredients.All(i => i.goods is Fluid) => Quality.Normal, + Item when nonQualityItemNames.Contains(target.name) => Quality.Normal, + // Everything else supports quality + _ => quality + }; + + object withQuality; + if (realQuality == quality) { + Type type = typeof(ConcreteObjectWithQuality<>).MakeGenericType(target.GetType()); + withQuality = Activator.CreateInstance(type, [target, realQuality])!; + } + else { + // This got changed back to normal quality. Get the previously stored object instead of making a new one. + withQuality = Get(target, realQuality); + } + + _cache[(target, quality)] = withQuality; + } + + /// + /// Represents a with an attached modifier. + /// + /// The concrete type of the quality-modified object. + private sealed class ConcreteObjectWithQuality(T target, Quality quality) : IObjectWithQuality where T : FactorioObject { + /// + public T target { get; } = target ?? throw new ArgumentNullException(nameof(target)); + /// + public Quality quality { get; } = quality ?? throw new ArgumentNullException(nameof(quality)); + + string IFactorioObjectWrapper.text => ((IFactorioObjectWrapper)target).text; + FactorioObject IFactorioObjectWrapper.target => target; + float IFactorioObjectWrapper.amount => ((IFactorioObjectWrapper)target).amount; + } } public class Effect { @@ -1028,10 +1050,10 @@ public TemperatureRange(int single) : this(single, single) { } public override readonly string ToString() { if (min == max) { - return min + "°"; + return LSs.Temperature.L(min); } - return min + "°-" + max + "°"; + return LSs.TemperatureRange.L(min, max); } 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 1980be06..a8019f6c 100644 --- a/Yafc.Model/Data/DataUtils.cs +++ b/Yafc.Model/Data/DataUtils.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using Google.OrTools.LinearSolver; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -160,10 +161,10 @@ public int Compare(T? x, T? y) { T? element = null; if (list.Any(t => t.IsAccessible())) { - recipeHint = "Hint: Complete milestones to enable ctrl+click"; + recipeHint = LSs.CtrlClickHintCompleteMilestones; } else { - recipeHint = "Hint: Mark a recipe as accessible to enable ctrl+click"; + recipeHint = LSs.CtrlClickHintMarkAccessible; } foreach (T elem in list) { @@ -175,11 +176,11 @@ public int Compare(T? x, T? y) { if (userFavorites.Contains(elem)) { if (!acceptOnlyFavorites || element == null) { element = elem; - recipeHint = "Hint: ctrl+click to add your favorited recipe"; + recipeHint = LSs.CtrlClickHintWillAddFavorite; acceptOnlyFavorites = true; } else { - recipeHint = "Hint: Cannot ctrl+click with multiple favorited recipes"; + recipeHint = LSs.CtrlClickHintMultipleFavorites; return null; } @@ -187,11 +188,11 @@ public int Compare(T? x, T? y) { else if (!acceptOnlyFavorites) { if (element == null) { element = elem; - recipeHint = excludeSpecial ? "Hint: ctrl+click to add the accessible normal recipe" : "Hint: ctrl+click to add the accessible recipe"; + recipeHint = excludeSpecial ? LSs.CtrlClickHintWillAddNormal : LSs.CtrlClickHintWillAddSpecial; } else { element = null; - recipeHint = "Hint: Set a favorite recipe to add it with ctrl+click"; + recipeHint = LSs.CtrlClickHintSetFavorite; acceptOnlyFavorites = true; } } @@ -542,47 +543,44 @@ public static string FormatTime(float time) { _ = amountBuilder.Clear(); if (time < 10f) { - return $"{time:#.#} seconds"; + return LSs.FormatTimeInSeconds.L(time.ToString("#.#")); } if (time < 60f) { - return $"{time:#} seconds"; + return LSs.FormatTimeInSeconds.L(time.ToString("#")); } if (time < 600f) { - return $"{time / 60f:#.#} minutes"; + return LSs.FormatTimeInMinutes.L((time / 60f).ToString("#.#")); } if (time < 3600f) { - return $"{time / 60f:#} minutes"; + return LSs.FormatTimeInMinutes.L((time / 60f).ToString("#")); } if (time < 36000f) { - return $"{time / 3600f:#.#} hours"; + return LSs.FormatTimeInHours.L((time / 3600f).ToString("#.#")); } - return $"{time / 3600f:#} hours"; + return LSs.FormatTimeInHours.L((time / 3600f).ToString("#")); } - public static string FormatAmount(float amount, UnitOfMeasure unit, string? prefix = null, string? suffix = null, bool precise = false) { + public static string FormatAmount(float amount, UnitOfMeasure unit, 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); + return FormatAmountRaw(amount, multiplier, unitSuffix, precise ? PreciseFormat : FormatSpec); } - public static string FormatAmountRaw(float amount, float unitMultiplier, string? unitSuffix, (char suffix, float multiplier, string format)[] formatSpec, string? prefix = null, string? suffix = null) { + public static string FormatAmountRaw(float amount, float unitMultiplier, string? unitSuffix, (char suffix, float multiplier, string format)[] formatSpec) { if (float.IsNaN(amount) || float.IsInfinity(amount)) { return "-"; } if (amount == 0f) { - return prefix + "0" + unitSuffix + suffix; + return "0" + unitSuffix; } _ = amountBuilder.Clear(); - if (prefix != null) { - _ = amountBuilder.Append(prefix); - } if (amount < 0) { _ = amountBuilder.Append('-'); @@ -600,10 +598,6 @@ public static string FormatAmountRaw(float amount, float unitMultiplier, string? _ = amountBuilder.Append(unitSuffix); - if (suffix != null) { - _ = amountBuilder.Append(suffix); - } - return amountBuilder.ToString(); } diff --git a/Yafc.Model/Data/Database.cs b/Yafc.Model/Data/Database.cs index f4d56112..f2088faa 100644 --- a/Yafc.Model/Data/Database.cs +++ b/Yafc.Model/Data/Database.cs @@ -12,13 +12,13 @@ public static class Database { public static Item[] allSciencePacks { get; internal set; } = null!; public static Dictionary objectsByTypeName { get; internal set; } = null!; public static Dictionary> fluidVariants { get; internal set; } = null!; - public static ObjectWithQuality voidEnergy { get; internal set; } = null!; - public static ObjectWithQuality science { get; internal set; } = null!; - public static ObjectWithQuality itemInput { get; internal set; } = null!; - public static ObjectWithQuality itemOutput { get; internal set; } = null!; - public static ObjectWithQuality electricity { get; internal set; } = null!; - public static ObjectWithQuality electricityGeneration { get; internal set; } = null!; - public static ObjectWithQuality heat { get; internal set; } = null!; + public static IObjectWithQuality voidEnergy { get; internal set; } = null!; + public static IObjectWithQuality science { get; internal set; } = null!; + public static IObjectWithQuality itemInput { get; internal set; } = null!; + public static IObjectWithQuality itemOutput { get; internal set; } = null!; + public static IObjectWithQuality electricity { get; internal set; } = null!; + public static IObjectWithQuality electricityGeneration { get; internal set; } = null!; + public static IObjectWithQuality heat { get; internal set; } = null!; public static Entity? character { get; internal set; } public static EntityCrafter[] allCrafters { get; internal set; } = null!; public static Module[] allModules { get; internal set; } = null!; diff --git a/Yafc.Model/Model/AutoPlanner.cs b/Yafc.Model/Model/AutoPlanner.cs index c52cb217..528cea4b 100644 --- a/Yafc.Model/Model/AutoPlanner.cs +++ b/Yafc.Model/Model/AutoPlanner.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Google.OrTools.LinearSolver; using Serilog; +using Yafc.I18n; using Yafc.UI; #nullable disable warnings // Disabling nullable in legacy code. @@ -15,7 +16,7 @@ public class AutoPlannerGoal { private Goods _item; public Goods item { get => _item; - set => _item = value ?? throw new ArgumentNullException(nameof(value), "Auto planner goal no longer exist"); + set => _item = value ?? throw new ArgumentNullException(nameof(value), LSs.AutoPlannerMissingGoal); } public float amount { get; set; } } @@ -118,7 +119,7 @@ public override async Task Solve(ProjectPage page) { logger.Information(bestFlowSolver.ExportModelAsLpFormat(false)); this.tiers = null; - return "Model has no solution"; + return LSs.AutoPlannerNoSolution; } Graph graph = new Graph(); diff --git a/Yafc.Model/Model/ModuleFillerParameters.cs b/Yafc.Model/Model/ModuleFillerParameters.cs index f07d23a5..94262fde 100644 --- a/Yafc.Model/Model/ModuleFillerParameters.cs +++ b/Yafc.Model/Model/ModuleFillerParameters.cs @@ -12,7 +12,7 @@ namespace Yafc.Model; /// The number of beacons to use. The total number of modules in beacons is this value times the number of modules that can be placed in a beacon. /// The module to place in the beacon. [Serializable] -public record BeaconOverrideConfiguration(ObjectWithQuality beacon, int beaconCount, ObjectWithQuality beaconModule); +public record BeaconOverrideConfiguration(IObjectWithQuality beacon, int beaconCount, IObjectWithQuality beaconModule); /// /// The result of applying the various beacon preferences to a crafter; this may result in a desired configuration where the beacon or module is not specified. @@ -21,7 +21,7 @@ public record BeaconOverrideConfiguration(ObjectWithQuality beacon /// The number of beacons to use. The total number of modules in beacons is this value times the number of modules that can be placed in a beacon. /// The module to place in the beacon, or if no beacons or beacon modules should be used. [Serializable] -public record BeaconConfiguration(ObjectWithQuality? beacon, int beaconCount, ObjectWithQuality? beaconModule) { +public record BeaconConfiguration(IObjectWithQuality? beacon, int beaconCount, IObjectWithQuality? beaconModule) { public static implicit operator BeaconConfiguration(BeaconOverrideConfiguration beaconConfiguration) => new(beaconConfiguration.beacon, beaconConfiguration.beaconCount, beaconConfiguration.beaconModule); } @@ -33,9 +33,9 @@ public static implicit operator BeaconConfiguration(BeaconOverrideConfiguration public class ModuleFillerParameters : ModelObject { private bool _fillMiners; private float _autoFillPayback; - private ObjectWithQuality? _fillerModule; - private ObjectWithQuality? _beacon; - private ObjectWithQuality? _beaconModule; + private IObjectWithQuality? _fillerModule; + private IObjectWithQuality? _beacon; + private IObjectWithQuality? _beaconModule; private int _beaconsPerBuilding = 8; public ModuleFillerParameters(ModelObject owner) : base(owner) => overrideCrafterBeacons.OverrideSettingChanging += ModuleFillerParametersChanging; @@ -48,15 +48,15 @@ public float autoFillPayback { get => _autoFillPayback; set => ChangeModuleFillerParameters(ref _autoFillPayback, value); } - public ObjectWithQuality? fillerModule { + public IObjectWithQuality? fillerModule { get => _fillerModule; set => ChangeModuleFillerParameters(ref _fillerModule, value); } - public ObjectWithQuality? beacon { + public IObjectWithQuality? beacon { get => _beacon; set => ChangeModuleFillerParameters(ref _beacon, value); } - public ObjectWithQuality? beaconModule { + public IObjectWithQuality? beaconModule { get => _beaconModule; set => ChangeModuleFillerParameters(ref _beaconModule, value); } @@ -114,10 +114,10 @@ public BeaconConfiguration GetBeaconsForCrafter(EntityCrafter? crafter) { internal void AutoFillBeacons(RecipeOrTechnology recipe, EntityCrafter entity, ref ModuleEffects effects, ref UsedModule used) { BeaconConfiguration beaconsToUse = GetBeaconsForCrafter(entity); - if (!recipe.flags.HasFlags(RecipeFlags.UsesMiningProductivity) && beaconsToUse.beacon is ObjectWithQuality beaconQual && beaconsToUse.beaconModule != null) { - EntityBeacon beacon = beaconQual.target; - effects.AddModules(beaconsToUse.beaconModule, beaconsToUse.beaconCount * beaconQual.GetBeaconEfficiency() * beacon.GetProfile(beaconsToUse.beaconCount) * beacon.moduleSlots, entity.allowedEffects); - used.beacon = beaconQual; + if (!recipe.flags.HasFlags(RecipeFlags.UsesMiningProductivity) && beaconsToUse.beacon != null && beaconsToUse.beaconModule != null) { + EntityBeacon beacon = beaconsToUse.beacon.target; + effects.AddModules(beaconsToUse.beaconModule, beaconsToUse.beaconCount * beaconsToUse.beacon.GetBeaconEfficiency() * beacon.GetProfile(beaconsToUse.beaconCount) * beacon.moduleSlots, entity.allowedEffects); + used.beacon = beaconsToUse.beacon; used.beaconCount = beaconsToUse.beaconCount; } } @@ -127,7 +127,7 @@ private void AutoFillModules((float recipeTime, float fuelUsagePerSecondPerBuild Quality quality = Quality.MaxAccessible; - ObjectWithQuality recipe = row.recipe; + IObjectWithQuality recipe = row.recipe; if (autoFillPayback > 0 && (fillMiners || !recipe.target.flags.HasFlags(RecipeFlags.UsesMiningProductivity))) { /* @@ -161,7 +161,7 @@ The payback time is calculated as the module cost divided by the economy gain pe } float bestEconomy = 0f; - ObjectWithQuality? usedModule = null; + IObjectWithQuality? usedModule = null; foreach (var module in Database.allModules) { if (module.IsAccessibleWithCurrentMilestones() && entity.CanAcceptModule(module.moduleSpecification) && recipe.target.CanAcceptModule(module)) { @@ -171,7 +171,7 @@ The payback time is calculated as the module cost divided by the economy gain pe if (economy > bestEconomy && module.Cost() / economy <= autoFillPayback) { bestEconomy = economy; - usedModule = new(module, quality); + usedModule = module.With(quality); } } } @@ -198,7 +198,7 @@ internal void GetModulesInfo((float recipeTime, float fuelUsagePerSecondPerBuild AutoFillModules(partialParams, row, entity, ref effects, ref used); } - private static void AddModuleSimple(ObjectWithQuality module, ref ModuleEffects effects, EntityCrafter entity, ref UsedModule used) { + private static void AddModuleSimple(IObjectWithQuality module, ref ModuleEffects effects, EntityCrafter entity, ref UsedModule used) { int fillerLimit = effects.GetModuleSoftLimit(module, entity.moduleSlots); effects.AddModules(module, fillerLimit); used.modules = [(module, fillerLimit, false)]; diff --git a/Yafc.Model/Model/ProductionSummary.cs b/Yafc.Model/Model/ProductionSummary.cs index f41ec6c3..97d4cdd6 100644 --- a/Yafc.Model/Model/ProductionSummary.cs +++ b/Yafc.Model/Model/ProductionSummary.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -36,7 +37,7 @@ public class ProductionSummaryEntry(ProductionSummaryGroup owner) : ModelObject< protected internal override void AfterDeserialize() { // Must be either page reference, or subgroup, not both if (subgroup == null && page == null) { - throw new NotSupportedException("Referenced page does not exist"); + throw new NotSupportedException(LSs.LoadErrorReferencedPageNotFound); } if (subgroup != null && page != null) { @@ -70,10 +71,10 @@ public Icon icon { public string name { get { if (page != null) { - return page.page?.name ?? "Page missing"; + return page.page?.name ?? LSs.LegacySummaryPageMissing; } - return "Broken entry"; + return LSs.LegacySummaryBrokenEntry; } } @@ -133,9 +134,8 @@ public void RefreshFlow() { } foreach (var link in subTable.allLinks) { - if (link.amount != 0) { - _ = flow.TryGetValue(link.goods, out float prevValue); - flow[link.goods] = prevValue + (link.amount * multiplier); + if (link.amount != 0 && !flow.ContainsKey(link.goods)) { + flow[link.goods] = link.amount * multiplier; } } } @@ -155,8 +155,8 @@ public void SetMultiplier(float newMultiplier) { } } -public class ProductionSummaryColumn(ProductionSummary owner, ObjectWithQuality goods) : ModelObject(owner) { - public ObjectWithQuality goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), "Object does not exist"); +public class ProductionSummaryColumn(ProductionSummary owner, IObjectWithQuality goods) : ModelObject(owner) { + public IObjectWithQuality goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), LSs.LoadErrorObjectDoesNotExist); } public class ProductionSummary : ProjectPageContents, IComparer<(IObjectWithQuality goods, float amount)> { @@ -169,7 +169,7 @@ public class ProductionSummary : ProjectPageContents, IComparer<(IObjectWithQual [SkipSerialization] public HashSet> columnsExist { get; } = []; public override void InitNew() { - columns.Add(new ProductionSummaryColumn(this, new(Database.electricity.target, Quality.Normal))); + columns.Add(new ProductionSummaryColumn(this, Database.electricity)); base.InitNew(); } diff --git a/Yafc.Model/Model/ProductionTable.GenuineRecipe.cs b/Yafc.Model/Model/ProductionTable.GenuineRecipe.cs index a2c2995c..e921a61b 100644 --- a/Yafc.Model/Model/ProductionTable.GenuineRecipe.cs +++ b/Yafc.Model/Model/ProductionTable.GenuineRecipe.cs @@ -35,8 +35,8 @@ public bool FindLink(IObjectWithQuality goods, [MaybeNullWhen(false)] out } // Pass all remaining calls through to the underlying RecipeRow. - public ObjectWithQuality? entity => row.entity; - public ObjectWithQuality? fuel => row.fuel; + public IObjectWithQuality? entity => row.entity; + public IObjectWithQuality? fuel => row.fuel; public float fixedBuildings => row.fixedBuildings; public double recipesPerSecond { get => row.recipesPerSecond; set => row.recipesPerSecond = value; } public float RecipeTime => ((IRecipeRow)row).RecipeTime; diff --git a/Yafc.Model/Model/ProductionTable.ImplicitLink.cs b/Yafc.Model/Model/ProductionTable.ImplicitLink.cs index c409586e..5b6e3ff7 100644 --- a/Yafc.Model/Model/ProductionTable.ImplicitLink.cs +++ b/Yafc.Model/Model/ProductionTable.ImplicitLink.cs @@ -6,17 +6,17 @@ public partial class ProductionTable { /// /// An implicitly-created link, used to ensure synthetic recipes are properly connected to the rest of the production table. /// - /// The linked . + /// The linked . /// The that owns this link. /// The ordinary link that caused the creation of this implicit link, and the link that will be displayed if the /// user requests the summary for this link. - private class ImplicitLink(ObjectWithQuality goods, ProductionTable owner, ProductionLink displayLink) : IProductionLink { + private class ImplicitLink(IObjectWithQuality goods, ProductionTable owner, ProductionLink displayLink) : IProductionLink { /// /// Always ; implicit links never allow over/under production. /// public LinkAlgorithm algorithm => LinkAlgorithm.Match; - public ObjectWithQuality goods { get; } = goods; + public IObjectWithQuality goods { get; } = goods; /// /// Always 0; implicit links never request additional production or consumption. diff --git a/Yafc.Model/Model/ProductionTable.ScienceDecomposition.cs b/Yafc.Model/Model/ProductionTable.ScienceDecomposition.cs index 5e8abb29..8425c6f3 100644 --- a/Yafc.Model/Model/ProductionTable.ScienceDecomposition.cs +++ b/Yafc.Model/Model/ProductionTable.ScienceDecomposition.cs @@ -13,9 +13,9 @@ public partial class ProductionTable { /// The (genuine) link for the output pack. /// The (implicit) link for the input pack. private class ScienceDecomposition(Goods pack, Quality quality, IProductionLink productLink, ImplicitLink ingredientLink) : IRecipeRow { - public ObjectWithQuality? entity => null; + public IObjectWithQuality? entity => null; - public ObjectWithQuality? fuel => null; + public IObjectWithQuality? fuel => null; /// /// Always 0; the solver must scale this recipe to match the available quality packs. diff --git a/Yafc.Model/Model/ProductionTable.cs b/Yafc.Model/Model/ProductionTable.cs index fec78aea..1fe24015 100644 --- a/Yafc.Model/Model/ProductionTable.cs +++ b/Yafc.Model/Model/ProductionTable.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using Google.OrTools.LinearSolver; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -161,7 +162,7 @@ public void RebuildLinkMap() { // If the link is at the current level, if (link.owner == this) { foreach (var quality in Database.qualities.all) { - ObjectWithQuality pack = goods.With(quality); + IObjectWithQuality pack = goods.With(quality); // and there is unlinked production here or anywhere deeper: (This test only succeeds for non-normal qualities) if (unlinkedProduction.Remove(pack)) { // Remove the quality pack, since it's about to be linked, // and create the decomposition. @@ -251,15 +252,15 @@ public bool Search(SearchQuery query) { /// If not , this method will select a crafter or lab that can use this fuel, assuming such an entity exists. /// For example, if the selected fuel is coal, the recipe will be configured with a burner assembler/lab if any are available. /// - public void AddRecipe(ObjectWithQuality recipe, IComparer ingredientVariantComparer, - ObjectWithQuality? selectedFuel = null, IObjectWithQuality? spentFuel = null) { + public void AddRecipe(IObjectWithQuality recipe, IComparer ingredientVariantComparer, + IObjectWithQuality? selectedFuel = null, IObjectWithQuality? spentFuel = null) { RecipeRow recipeRow = new RecipeRow(this, recipe); this.RecordUndo().recipes.Add(recipeRow); EntityCrafter? selectedFuelCrafter = GetSelectedFuelCrafter(recipe.target, selectedFuel); EntityCrafter? spentFuelRecipeCrafter = GetSpentFuelCrafter(recipe.target, spentFuel); - recipeRow.entity = (selectedFuelCrafter ?? spentFuelRecipeCrafter ?? recipe.target.crafters.AutoSelect(DataUtils.FavoriteCrafter), Quality.Normal); + recipeRow.entity = (selectedFuelCrafter ?? spentFuelRecipeCrafter ?? recipe.target.crafters.AutoSelect(DataUtils.FavoriteCrafter)).With(Quality.Normal); if (recipeRow.entity != null) { recipeRow.fuel = GetSelectedFuel(selectedFuel, recipeRow) @@ -275,7 +276,7 @@ public void AddRecipe(ObjectWithQuality recipe, IComparer? selectedFuel) => + private static EntityCrafter? GetSelectedFuelCrafter(RecipeOrTechnology recipe, IObjectWithQuality? selectedFuel) => selectedFuel?.target.fuelFor.OfType() .Where(e => e.recipes.Contains(recipe)) .AutoSelect(DataUtils.FavoriteCrafter); @@ -290,7 +291,7 @@ public void AddRecipe(ObjectWithQuality recipe, IComparer? GetFuelForSpentFuel(IObjectWithQuality? spentFuel, [NotNull] RecipeRow recipeRow) { + private static IObjectWithQuality? GetFuelForSpentFuel(IObjectWithQuality? spentFuel, [NotNull] RecipeRow recipeRow) { if (spentFuel is null) { return null; } @@ -299,7 +300,7 @@ public void AddRecipe(ObjectWithQuality recipe, IComparer? GetSelectedFuel(ObjectWithQuality? selectedFuel, [NotNull] RecipeRow recipeRow) => + private static IObjectWithQuality? GetSelectedFuel(IObjectWithQuality? selectedFuel, [NotNull] RecipeRow recipeRow) => // Skipping AutoSelect since there will only be one result at most. recipeRow.entity?.target.energy?.fuels.FirstOrDefault(e => e == selectedFuel?.target)?.With(selectedFuel!.quality); @@ -607,14 +608,14 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction } else { if (result == Solver.ResultStatus.INFEASIBLE) { - return "YAFC failed to solve the model and to find deadlock loops. As a result, the model was not updated."; + return LSs.ProductionTableNoSolutionAndNoDeadlocks; } if (result == Solver.ResultStatus.ABNORMAL) { - return "This model has numerical errors (probably too small or too large numbers) and cannot be solved"; + return LSs.ProductionTableNumericalErrors; } - return "Unaccounted error: MODEL_" + result; + return LSs.ProductionTableUnexpectedError.L(result); } } @@ -643,7 +644,7 @@ private static void AddLinkCoefficient(Constraint cst, Variable var, IProduction CalculateFlow(null); - return builtCountExceeded ? "This model requires more buildings than are currently built" : null; + return builtCountExceeded ? LSs.ProductionTableRequiresMoreBuildings : null; } /// diff --git a/Yafc.Model/Model/ProductionTableContent.cs b/Yafc.Model/Model/ProductionTableContent.cs index 19a8e0ec..12f7d924 100644 --- a/Yafc.Model/Model/ProductionTableContent.cs +++ b/Yafc.Model/Model/ProductionTableContent.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -16,7 +17,7 @@ public struct ModuleEffects { public readonly float speedMod => MathF.Max(1f + speed, 0.2f); public readonly float energyUsageMod => MathF.Max(1f + consumption, 0.2f); public readonly float qualityMod => MathF.Max(quality, 0); - public void AddModules(ObjectWithQuality module, float count, AllowedEffects allowedEffects = AllowedEffects.All) { + public void AddModules(IObjectWithQuality module, float count, AllowedEffects allowedEffects = AllowedEffects.All) { ModuleSpecification spec = module.target.moduleSpecification; Quality quality = module.quality; if (allowedEffects.HasFlags(AllowedEffects.Speed)) { @@ -36,7 +37,7 @@ public void AddModules(ObjectWithQuality module, float count, AllowedEff } } - public readonly int GetModuleSoftLimit(ObjectWithQuality module, int hardLimit) { + public readonly int GetModuleSoftLimit(IObjectWithQuality module, int hardLimit) { ModuleSpecification spec = module.target.moduleSpecification; Quality quality = module.quality; @@ -56,8 +57,8 @@ public readonly int GetModuleSoftLimit(ObjectWithQuality module, int har /// One module that is (or will be) applied to a , and the number of times it should appear. /// /// Immutable. To modify, modify the owning . -public class RecipeRowCustomModule(ModuleTemplate owner, ObjectWithQuality module, int fixedCount = 0) : ModelObject(owner) { - public ObjectWithQuality module { get; } = module ?? throw new ArgumentNullException(nameof(module)); +public class RecipeRowCustomModule(ModuleTemplate owner, IObjectWithQuality module, int fixedCount = 0) : ModelObject(owner) { + public IObjectWithQuality module { get; } = module ?? throw new ArgumentNullException(nameof(module)); public int fixedCount { get; } = fixedCount; } @@ -70,7 +71,7 @@ public class ModuleTemplate : ModelObject { /// /// The beacon to use, if any, for the associated . /// - public ObjectWithQuality? beacon { get; } + public IObjectWithQuality? beacon { get; } /// /// The modules, if any, to directly insert into the crafting entity. /// @@ -80,7 +81,7 @@ public class ModuleTemplate : ModelObject { /// public ReadOnlyCollection beaconList { get; private set; } = new([]); // Must be a distinct collection object to accommodate the deserializer. - private ModuleTemplate(ModelObject owner, ObjectWithQuality? beacon) : base(owner) => this.beacon = beacon; + private ModuleTemplate(ModelObject owner, IObjectWithQuality? beacon) : base(owner) => this.beacon = beacon; public bool IsCompatibleWith([NotNullWhen(true)] RecipeRow? row) { if (row?.entity == null) { @@ -111,9 +112,9 @@ public bool IsCompatibleWith([NotNullWhen(true)] RecipeRow? row) { } internal void GetModulesInfo(RecipeRow row, EntityCrafter entity, ref ModuleEffects effects, ref UsedModule used, ModuleFillerParameters? filler) { - List<(ObjectWithQuality module, int count, bool beacon)> buffer = []; + List<(IObjectWithQuality module, int count, bool beacon)> buffer = []; int beaconedModules = 0; - ObjectWithQuality? nonBeacon = null; + IObjectWithQuality? nonBeacon = null; used.modules = null; int remaining = entity.moduleSlots; @@ -186,7 +187,7 @@ internal static ModuleTemplate Build(ModelObject owner, ModuleTemplateBuilder bu return modules; - ReadOnlyCollection convertList(List<(ObjectWithQuality module, int fixedCount)> list) + ReadOnlyCollection convertList(List<(IObjectWithQuality module, int fixedCount)> list) => list.Select(m => new RecipeRowCustomModule(modules, m.module, m.fixedCount)).ToList().AsReadOnly(); } } @@ -198,15 +199,15 @@ public class ModuleTemplateBuilder { /// /// The beacon to be stored in after building. /// - public ObjectWithQuality? beacon { get; set; } + public IObjectWithQuality? beacon { get; set; } /// /// The list of s and counts to be stored in after building. /// - public List<(ObjectWithQuality module, int fixedCount)> list { get; set; } = []; + public List<(IObjectWithQuality module, int fixedCount)> list { get; set; } = []; /// /// The list of s and counts to be stored in after building. /// - public List<(ObjectWithQuality module, int fixedCount)> beaconList { get; set; } = []; + public List<(IObjectWithQuality module, int fixedCount)> beaconList { get; set; } = []; /// /// Builds a from this . @@ -257,8 +258,8 @@ public interface IGroupedElement { /// public interface IRecipeRow { // Variable (user-configured, for RecipeRow) properties - ObjectWithQuality? entity { get; } - ObjectWithQuality? fuel { get; } + IObjectWithQuality? entity { get; } + IObjectWithQuality? fuel { get; } /// /// If not zero, the fixed building count to be used by the solver. /// @@ -304,15 +305,15 @@ public interface IRecipeRow { /// Represents a row in a production table that can be configured by the user. /// public class RecipeRow : ModelObject, IGroupedElement, IRecipeRow { - private ObjectWithQuality? _entity; - private ObjectWithQuality? _fuel; + private IObjectWithQuality? _entity; + private IObjectWithQuality? _fuel; private float _fixedBuildings; - private ObjectWithQuality? _fixedProduct; + private IObjectWithQuality? _fixedProduct; private ModuleTemplate? _modules; - public ObjectWithQuality recipe { get; } + public IObjectWithQuality recipe { get; } // Variable parameters - public ObjectWithQuality? entity { + public IObjectWithQuality? entity { get => _entity; set { if (_entity == value) { @@ -344,7 +345,7 @@ public ObjectWithQuality? entity { } } } - public ObjectWithQuality? fuel { + public IObjectWithQuality? fuel { get => _fuel; set { if (SerializationMap.IsDeserializing || fixedBuildings == 0 || _fuel == value) { @@ -404,11 +405,11 @@ public float fixedBuildings { /// /// If not , is set to control the consumption of this ingredient. /// - public ObjectWithQuality? fixedIngredient { get; set; } + public IObjectWithQuality? fixedIngredient { get; set; } /// /// If not , is set to control the production of this product. /// - public ObjectWithQuality? fixedProduct { + public IObjectWithQuality? fixedProduct { get => _fixedProduct; set { if (value == null) { @@ -416,7 +417,7 @@ public ObjectWithQuality? fixedProduct { } else { // This takes advantage of the fact that ObjectWithQuality automatically downgrades high-quality fluids (etc.) to normal. - var products = recipe.target.products.AsEnumerable().Select(p => new ObjectWithQuality(p.goods, recipe.quality)); + var products = recipe.target.products.AsEnumerable().Select(p => p.goods.With(recipe.quality)); if (value != Database.itemOutput && products.All(p => p != value)) { // The UI doesn't know the difference between a product and a spent fuel, but we care about the difference _fixedProduct = null; @@ -457,7 +458,7 @@ public ObjectWithQuality? fixedProduct { public Module module { set { if (value != null) { - modules = new ModuleTemplateBuilder { list = { (new(value, Quality.Normal), 0) } }.Build(this); + modules = new ModuleTemplateBuilder { list = { (value.With(Quality.Normal), 0) } }.Build(this); } } } @@ -498,7 +499,7 @@ private IEnumerable BuildIngredients(bool forSolver) { float factor = forSolver ? 1 : (float)recipesPerSecond; // The solver needs the ingredients for one recipe, to produce recipesPerSecond. for (int i = 0; i < recipe.target.ingredients.Length; i++) { Ingredient ingredient = recipe.target.ingredients[i]; - ObjectWithQuality option = (ingredient.variants == null ? ingredient.goods : GetVariant(ingredient.variants)).With(recipe.quality); + IObjectWithQuality option = (ingredient.variants == null ? ingredient.goods : GetVariant(ingredient.variants)).With(recipe.quality); yield return (option, ingredient.amount * factor, links.ingredients[i], i); } } @@ -566,8 +567,9 @@ private IEnumerable BuildProducts(bool forSolver) { } if (!handledFuel) { - // null-forgiving: handledFuel is always true if spentFuel is null. - yield return (new(spentFuel!.target, spentFuel.quality), parameters.fuelUsagePerSecondPerRecipe * factor, links.spentFuel, 0, null); + // null-forgiving (both): handledFuel is always false when running the solver. + // equivalently: We do not enter this block when a non-null Goods is required. + yield return (spentFuel!, parameters.fuelUsagePerSecondPerRecipe * factor, links.spentFuel, 0, null); } } @@ -625,11 +627,11 @@ public void ChangeVariant(T was, T now) where T : FactorioObject { [MemberNotNullWhen(true, nameof(subgroup))] public bool isOverviewMode => subgroup != null && !subgroup.expanded; - public float buildingCount => (float)recipesPerSecond * parameters.recipeTime; + public float buildingCount => (float)(recipesPerSecond * parameters.recipeTime); public bool visible { get; internal set; } = true; - public RecipeRow(ProductionTable owner, ObjectWithQuality recipe) : base(owner) { - this.recipe = recipe ?? throw new ArgumentNullException(nameof(recipe), "Recipe does not exist"); + public RecipeRow(ProductionTable owner, IObjectWithQuality recipe) : base(owner) { + this.recipe = recipe ?? throw new ArgumentNullException(nameof(recipe), LSs.LoadErrorRecipeDoesNotExist); links = new RecipeLinks { ingredients = new ProductionLink[recipe.target.ingredients.Length], @@ -648,7 +650,7 @@ public void RemoveFixedModules() { CreateUndoSnapshot(); modules = null; } - public void SetFixedModule(ObjectWithQuality module) { + public void SetFixedModule(IObjectWithQuality module) { ModuleTemplateBuilder builder = modules?.GetBuilder() ?? new(); builder.list = [(module, 0)]; this.RecordUndo().modules = builder.Build(this); @@ -699,7 +701,7 @@ internal void GetModulesInfo((float recipeTime, float fuelUsagePerSecondPerBuild /// private class ChangeModulesOrEntity : IDisposable { private readonly RecipeRow row; - private readonly ObjectWithQuality? oldFuel; + private readonly IObjectWithQuality? oldFuel; private readonly RecipeParameters oldParameters; public ChangeModulesOrEntity(RecipeRow row) { @@ -793,7 +795,7 @@ public enum LinkAlgorithm { /// public interface IProductionLink { internal LinkAlgorithm algorithm { get; } - ObjectWithQuality goods { get; } + IObjectWithQuality goods { get; } float amount { get; } internal int solverIndex { get; set; } ProductionLink.Flags flags { get; set; } @@ -815,7 +817,7 @@ public interface IProductionLink { /// /// A Link is goods whose production and consumption is attempted to be balanced by YAFC across the sheet. /// -public class ProductionLink(ProductionTable group, ObjectWithQuality goods) : ModelObject(group), IProductionLink { +public class ProductionLink(ProductionTable group, IObjectWithQuality goods) : ModelObject(group), IProductionLink { [Flags] public enum Flags { // This enum uses powers of two to represent its state. @@ -840,7 +842,7 @@ public enum Flags { HasProductionAndConsumption = HasProduction | HasConsumption, } - public ObjectWithQuality goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), "Linked product does not exist"); + public IObjectWithQuality goods { get; } = goods ?? throw new ArgumentNullException(nameof(goods), LSs.LoadErrorLinkedProductDoesNotExist); public float amount { get; set; } public LinkAlgorithm algorithm { get; set; } public UnitOfMeasure flowUnitOfMeasure => goods.target.flowUnitOfMeasure; @@ -867,27 +869,27 @@ public enum Flags { public IEnumerable LinkWarnings { get { if (!flags.HasFlags(Flags.HasProduction)) { - yield return "This link has no production (Link ignored)"; + yield return LSs.LinkWarningNoProduction; } if (!flags.HasFlags(Flags.HasConsumption)) { - yield return "This link has no consumption (Link ignored)"; + yield return LSs.LinkWarningNoConsumption; } if (flags.HasFlags(Flags.ChildNotMatched)) { - yield return "Nested table link has unmatched production/consumption. These unmatched products are not captured by this link."; + yield return LSs.LinkWarningUnmatchedNestedLink; } if (!flags.HasFlags(Flags.HasProductionAndConsumption) && owner.owner is RecipeRow recipeRow && recipeRow.FindLink(goods, out _)) { - yield return "Nested tables have their own set of links that DON'T connect to parent links. To connect this product to the outside, remove this link."; + yield return LSs.LinkMessageRemoveToLinkWithParent; } if (flags.HasFlags(Flags.LinkRecursiveNotMatched)) { if (notMatchedFlow <= 0f) { - yield return "YAFC was unable to satisfy this link (Negative feedback loop). This doesn't mean that this link is the problem, but it is part of the loop."; + yield return LSs.LinkWarningNegativeFeedback; } else { - yield return "YAFC was unable to satisfy this link (Overproduction). You can allow overproduction for this link to solve the error."; + yield return LSs.LinkWarningNeedsOverproduction; } } } @@ -897,7 +899,7 @@ public IEnumerable LinkWarnings { /// /// An ingredient for a recipe row, as reported to the UI. /// -public record RecipeRowIngredient(ObjectWithQuality? Goods, float Amount, ProductionLink? Link, Goods[]? Variants) { +public record RecipeRowIngredient(IObjectWithQuality? Goods, float Amount, ProductionLink? Link, Goods[]? Variants) { /// /// Convert from a (the form initially generated when reporting ingredients) to a /// . @@ -909,7 +911,7 @@ internal static RecipeRowIngredient FromSolver(SolverIngredient value) /// /// A product from a recipe row, as reported to the UI. /// -public record RecipeRowProduct(ObjectWithQuality? Goods, float Amount, IProductionLink? Link, float? PercentSpoiled) { +public record RecipeRowProduct(IObjectWithQuality? Goods, float Amount, IProductionLink? Link, float? PercentSpoiled) { /// /// Convert from a (the form initially generated when reporting products) to a . /// @@ -921,8 +923,8 @@ internal static RecipeRowProduct FromSolver(SolverProduct value) /// An ingredient for a recipe row, as reported to the solver. /// Alternatively, an intermediate value that will be used by the UI after conversion using . /// -internal record SolverIngredient(ObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex) { - public static implicit operator SolverIngredient((ObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex) value) +internal record SolverIngredient(IObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex) { + public static implicit operator SolverIngredient((IObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex) value) => new(value.Goods, value.Amount, value.Link, value.LinkIndex); } @@ -930,7 +932,7 @@ public static implicit operator SolverIngredient((ObjectWithQuality Goods /// A product for a recipe row, as reported to the solver. /// Alternatively, an intermediate value that will be used by the UI after conversion using . /// -internal record SolverProduct(ObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex, float? PercentSpoiled) { - public static implicit operator SolverProduct((ObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex, float? PercentSpoiled) value) +internal record SolverProduct(IObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex, float? PercentSpoiled) { + public static implicit operator SolverProduct((IObjectWithQuality Goods, float Amount, IProductionLink? Link, int LinkIndex, float? PercentSpoiled) value) => new(value.Goods, value.Amount, value.Link, value.LinkIndex, value.PercentSpoiled); } diff --git a/Yafc.Model/Model/Project.cs b/Yafc.Model/Model/Project.cs index 011be8d9..deb95f84 100644 --- a/Yafc.Model/Model/Project.cs +++ b/Yafc.Model/Model/Project.cs @@ -5,6 +5,7 @@ using System.Runtime.Serialization; using System.Text.Json; using System.Text.RegularExpressions; +using Yafc.I18n; namespace Yafc.Model; @@ -78,47 +79,62 @@ protected internal override void ThisChanged(bool visualOnly) { } public static Project ReadFromFile(string path, ErrorCollector collector, bool useMostRecent) { - Project? project; - - var highestAutosaveIndex = 0; - - // Check whether there is an autosave that is saved at a later time than the current save. - if (useMostRecent) { - var savetime = File.GetLastWriteTimeUtc(path); - var highestAutosave = Enumerable - .Range(1, AutosaveRollingLimit) - .Select(i => new { - Path = GenerateAutosavePath(path, i), - Index = i, - LastWriteTimeUtc = File.GetLastWriteTimeUtc(GenerateAutosavePath(path, i)) - }) - .MaxBy(s => s.LastWriteTimeUtc); - - if (highestAutosave != null && highestAutosave.LastWriteTimeUtc > savetime) { - highestAutosaveIndex = highestAutosave.Index; - path = highestAutosave.Path; - } + if (string.IsNullOrWhiteSpace(path)) { + // Empty paths don't have autosaves. + useMostRecent = false; } - if (!string.IsNullOrEmpty(path) && File.Exists(path)) { - project = Read(File.ReadAllBytes(path), collector); + try { + return read(path, collector, useMostRecent); } - else { - project = new Project(); + catch when (useMostRecent) { + collector.Error(LSs.ErrorLoadingAutosave, ErrorSeverity.Important); + return read(path, collector, false); } - // If an Auto Save is used to open the project we want remove the 'autosave' part so when the user - // manually saves the file next time it saves the 'main' save instead of the generated save file. - if (path != null) { - var autosaveRegex = new Regex("-autosave-[0-9].yafc$"); - path = autosaveRegex.Replace(path, ".yafc"); - } + static Project read(string path, ErrorCollector collector, bool useMostRecent) { + Project? project; + + var highestAutosaveIndex = 0; + + // Check whether there is an autosave that is saved at a later time than the current save. + if (useMostRecent) { + var savetime = File.GetLastWriteTimeUtc(path); + var highestAutosave = Enumerable + .Range(1, AutosaveRollingLimit) + .Select(i => new { + Path = GenerateAutosavePath(path, i), + Index = i, + LastWriteTimeUtc = File.GetLastWriteTimeUtc(GenerateAutosavePath(path, i)) + }) + .MaxBy(s => s.LastWriteTimeUtc); + + if (highestAutosave != null && highestAutosave.LastWriteTimeUtc > savetime) { + highestAutosaveIndex = highestAutosave.Index; + path = highestAutosave.Path; + } + } - project.attachedFileName = path; - project.lastSavedVersion = project.projectVersion; - project.autosaveIndex = highestAutosaveIndex; + if (!string.IsNullOrEmpty(path) && File.Exists(path)) { + project = Read(File.ReadAllBytes(path), collector); + } + else { + project = new Project(); + } - return project; + // If an Auto Save is used to open the project we want remove the 'autosave' part so when the user + // manually saves the file next time it saves the 'main' save instead of the generated save file. + if (path != null) { + var autosaveRegex = new Regex("-autosave-[0-9].yafc$"); + path = autosaveRegex.Replace(path, ".yafc"); + } + + project.attachedFileName = path; + project.lastSavedVersion = project.projectVersion; + project.autosaveIndex = highestAutosaveIndex; + + return project; + } } public static Project Read(byte[] bytes, ErrorCollector collector) { @@ -129,11 +145,11 @@ public static Project Read(byte[] bytes, ErrorCollector collector) { project = SerializationMap.DeserializeFromJson(null, ref reader, context); if (!reader.IsFinalBlock) { - collector.Error("Json was not consumed to the end!", ErrorSeverity.MajorDataLoss); + collector.Error(LSs.LoadErrorDidNotReadAllData, ErrorSeverity.MajorDataLoss); } if (project == null) { - throw new SerializationException("Unable to load project file"); + throw new SerializationException(LSs.LoadErrorUnableToLoadFile); } project.justCreated = false; @@ -141,7 +157,7 @@ public static Project Read(byte[] bytes, ErrorCollector collector) { if (version != currentYafcVersion) { if (version > currentYafcVersion) { - collector.Error("This file was created with future YAFC version. This may lose data.", ErrorSeverity.Important); + collector.Error(LSs.LoadWarningNewerVersion, ErrorSeverity.Important); } project.yafcVersion = currentYafcVersion.ToString(); @@ -171,7 +187,7 @@ public void Save(Stream stream) { } public void PerformAutoSave() { - if (attachedFileName != null && lastAutoSavedVersion != projectVersion) { + if (!string.IsNullOrWhiteSpace(attachedFileName) && lastAutoSavedVersion != projectVersion) { autosaveIndex = (autosaveIndex % AutosaveRollingLimit) + 1; var fileName = GenerateAutosavePath(attachedFileName, autosaveIndex); diff --git a/Yafc.Model/Model/ProjectPage.cs b/Yafc.Model/Model/ProjectPage.cs index 180b48a2..9f91467b 100644 --- a/Yafc.Model/Model/ProjectPage.cs +++ b/Yafc.Model/Model/ProjectPage.cs @@ -1,12 +1,13 @@ using System; using System.Threading.Tasks; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; public class ProjectPage : ModelObject { public FactorioObject? icon { get; set; } - public string name { get; set; } = "New page"; + public string name { get; set; } = LSs.DefaultNewPageName; public Guid guid { get; private set; } public Type contentType { get; } public ProjectPageContents content { get; } diff --git a/Yafc.Model/Model/QualityExtensions.cs b/Yafc.Model/Model/QualityExtensions.cs index fd4675a9..f4d29b2c 100644 --- a/Yafc.Model/Model/QualityExtensions.cs +++ b/Yafc.Model/Model/QualityExtensions.cs @@ -28,7 +28,7 @@ public static float StormPotentialPerTick(this IObjectWithQuality obj) => obj.target.name + "@" + obj.quality.name; public static IObjectWithQuality? mainProduct(this IObjectWithQuality recipe) => - (ObjectWithQuality?)(recipe.target.mainProduct, recipe.quality); + recipe.target.mainProduct.With(recipe.quality); public static bool CanAcceptModule(this IObjectWithQuality recipe, Module module) => recipe.target.CanAcceptModule(module); @@ -38,43 +38,44 @@ public static bool CanAcceptModule(this IObjectWithQuality r public static IObjectWithQuality? FuelResult(this IObjectWithQuality? goods) => (goods?.target as Item)?.fuelResult.With(goods!.quality); // null-forgiving: With is not called if goods is null. - [return: NotNullIfNotNull(nameof(obj))] - public static ObjectWithQuality? With(this T? obj, Quality quality) where T : FactorioObject => (obj, quality); + /// + /// Gets the representing a with an attached modifier. + /// + /// The type of the quality-oblivious object. This must be or a derived type. + /// The quality-oblivious object to be converted, or to return . + /// The desired quality. If does not accept quality modifiers (e.g. fluids or technologies), + /// the return value will have normal quality regardless of the value of this parameter. This parameter must still be a valid + /// (not ), even if it will be ignored. + /// This automatically checks for invalid combinations, such as rare s. It will instead return a normal quality + /// object in that case. + /// A valid quality-aware object, respecting the limitations of what objects can exist at non-normal quality, or + /// if was . + [return: NotNullIfNotNull(nameof(target))] + public static IObjectWithQuality? With(this T? target, Quality quality) where T : FactorioObject => ObjectWithQuality.Get(target, quality); /// - /// If possible, converts an into one with a different generic parameter. + /// Gets the with the same and the specified + /// . /// - /// The desired type parameter for the output . - /// The input to be converted. - /// If ?.target is , an with - /// the same target and quality as . Otherwise, . - /// if the conversion was successful, or if it was not. - public static bool Is(this IObjectWithQuality? obj, [NotNullWhen(true)] out IObjectWithQuality? result) where T : FactorioObject { - result = obj.As(); - return result is not null; - } + /// The corresponding quality-oblivious type. This must be or a derived type. + /// An object containing the desired , or to return + /// . + /// The desired quality. If .target does not + /// accept quality modifiers (e.g. fluids or technologies), the return value will have normal quality regardless of the value of this + /// parameter. + /// This automatically checks for invalid combinations, such as rare s. It will instead return a normal quality + /// object in that case. + /// A valid quality-aware object, respecting the limitations of what objects can exist at non-normal quality, or + /// if was . + [return: NotNullIfNotNull(nameof(target))] + public static IObjectWithQuality? With(this IObjectWithQuality? target, Quality quality) where T : FactorioObject + => ObjectWithQuality.Get(target?.target, quality); /// /// Tests whether an can be converted to one with a different generic parameter. /// /// The desired type parameter for the conversion to be tested. - /// The input to be converted. + /// The input to be tested. /// if the conversion is possible, or if it was not. - public static bool Is(this IObjectWithQuality? obj) where T : FactorioObject => obj?.target is T; - - /// - /// If possible, converts an into one with a different generic parameter. - /// - /// The desired type parameter for the output . - /// The input to be converted. - /// If ?.target is , an with - /// the same target and quality as . Otherwise, . - /// if the conversion was successful, or if it was not. - public static IObjectWithQuality? As(this IObjectWithQuality? obj) where T : FactorioObject { - if (obj is null or IObjectWithQuality) { - return obj as IObjectWithQuality; - } - // Use the conversion because it permits a null target. The constructor does not. - return (ObjectWithQuality?)(obj.target as T, obj.quality); - } + public static bool Is(this IObjectWithQuality? target) where T : FactorioObject => target?.target is T; } diff --git a/Yafc.Model/Model/RecipeParameters.cs b/Yafc.Model/Model/RecipeParameters.cs index b04ca74d..62058df3 100644 --- a/Yafc.Model/Model/RecipeParameters.cs +++ b/Yafc.Model/Model/RecipeParameters.cs @@ -13,6 +13,7 @@ public enum WarningFlags { AsteroidCollectionNotModelled = 1 << 3, AssumesFulgoraAndModel = 1 << 4, UselessQuality = 1 << 5, + ExcessProductivity = 1 << 6, // Static errors EntityNotSpecified = 1 << 8, @@ -31,8 +32,8 @@ public enum WarningFlags { } public struct UsedModule { - public (ObjectWithQuality module, int count, bool beacon)[]? modules; - public ObjectWithQuality? beacon; + public (IObjectWithQuality module, int count, bool beacon)[]? modules; + public IObjectWithQuality? beacon; public int beaconCount; } @@ -50,9 +51,9 @@ internal class RecipeParameters(float recipeTime, float fuelUsagePerSecondPerBui public static RecipeParameters CalculateParameters(IRecipeRow row) { WarningFlags warningFlags = 0; - ObjectWithQuality? entity = row.entity; - ObjectWithQuality? recipe = row.RecipeRow?.recipe; - ObjectWithQuality? fuel = row.fuel; + IObjectWithQuality? entity = row.entity; + IObjectWithQuality? recipe = row.RecipeRow?.recipe; + IObjectWithQuality? fuel = row.fuel; float recipeTime, fuelUsagePerSecondPerBuilding = 0, productivity, speed, consumption; ModuleEffects activeEffects = default; UsedModule modules = default; @@ -175,6 +176,11 @@ public static RecipeParameters CalculateParameters(IRecipeRow row) { activeEffects.speed += speed; activeEffects.consumption += consumption; + if (recipe.target is Recipe { maximumProductivity: float maxProd } && activeEffects.productivity > maxProd) { + warningFlags |= WarningFlags.ExcessProductivity; + activeEffects.productivity = maxProd; + } + recipeTime /= activeEffects.speedMod; fuelUsagePerSecondPerBuilding *= activeEffects.energyUsageMod; diff --git a/Yafc.Model/Serialization/ErrorCollector.cs b/Yafc.Model/Serialization/ErrorCollector.cs index b863e3a9..83fadf66 100644 --- a/Yafc.Model/Serialization/ErrorCollector.cs +++ b/Yafc.Model/Serialization/ErrorCollector.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Text.Json; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -34,7 +35,7 @@ public void Error(string message, ErrorSeverity severity) { 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))]; + .Select(x => (x.Value == 1 ? x.Key.message : LSs.RepeatedError.L(x.Key.message, x.Value), x.Key.severity))]; public void Exception(Exception exception, string message, ErrorSeverity errorSeverity) { while (exception.InnerException != null) { @@ -46,14 +47,8 @@ public void Exception(Exception exception, string message, ErrorSeverity errorSe if (exception is JsonException) { s += "unexpected or invalid json"; } - else if (exception is ArgumentNullException argnull) { - s += argnull.Message; - } - else if (exception is NotSupportedException notSupportedException) { - s += notSupportedException.Message; - } - else if (exception is InvalidOperationException unexpectedNull) { - s += unexpectedNull.Message; + else if (exception is ArgumentNullException or NotSupportedException or InvalidOperationException) { + s += exception.Message; } else { s += exception.GetType().Name; diff --git a/Yafc.Model/Serialization/PropertySerializers.cs b/Yafc.Model/Serialization/PropertySerializers.cs index 38410cc1..4980ccdc 100644 --- a/Yafc.Model/Serialization/PropertySerializers.cs +++ b/Yafc.Model/Serialization/PropertySerializers.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using Yafc.I18n; namespace Yafc.Model; @@ -64,6 +65,14 @@ protected TPropertyType getter(TOwner owner) { } } +/// +/// With ValueSerializer<>, serializes and deserializes all values +/// stored in writable properties. +/// +/// The type (not a ) that contains this property. +/// The declared type of this property. This type must be listed in +/// . +/// This property's . internal sealed class ValuePropertySerializer(PropertyInfo property) : PropertySerializer(property, PropertyType.Normal, true) where TOwner : class { @@ -81,13 +90,8 @@ internal sealed class ValuePropertySerializer(PropertyInf var result = _getter(owner); - if (result == null) { - if (CanBeNull) { - return default; - } - else { - throw new InvalidOperationException($"{property.DeclaringType}.{propertyName} must not return null."); - } + if (result == null && !CanBeNull) { + throw new InvalidOperationException($"{property.DeclaringType}.{propertyName} must not return null."); } return result; @@ -111,7 +115,12 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader public override bool CanBeNull => ValueSerializer.CanBeNull; } -// Serializes read-only sub-value with support of polymorphism +/// +/// With ModelObjectSerializer<>, serializes and deserializes +/// s stored in non-writable properties. +/// +/// The type ( or a derived type) that contains this property. +/// The declared type ( or a derived type) of this property. internal class ReadOnlyReferenceSerializer : PropertySerializer where TOwner : ModelObject where TPropertyType : ModelObject { @@ -143,7 +152,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); + context.Error(LSs.LoadWarningUnexpectedObject, ErrorSeverity.MinorDataLoss); reader.Skip(); } else if (instance.GetType() == typeof(TPropertyType)) { @@ -159,37 +168,16 @@ public override void SerializeToUndoBuilder(TOwner owner, UndoSnapshotBuilder bu public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader reader) { } } -internal class ReadWriteReferenceSerializer(PropertyInfo property) : - ReadOnlyReferenceSerializer(property, PropertyType.Normal, true) - where TOwner : ModelObject where TPropertyType : ModelObject { - - private void setter(TOwner owner, TPropertyType? value) - => _setter(owner ?? throw new ArgumentNullException(nameof(owner)), value); - private new TPropertyType? getter(TOwner owner) - => _getter(owner ?? throw new ArgumentNullException(nameof(owner))); - - public override void DeserializeFromJson(TOwner owner, ref Utf8JsonReader reader, DeserializationContext context) { - if (reader.TokenType == JsonTokenType.Null) { - return; - } - - var instance = getter(owner); - - if (instance == null) { - setter(owner, SerializationMap.DeserializeFromJson(owner, ref reader, context)); - return; - } - - base.DeserializeFromJson(owner, ref reader, context); - } - - public override void SerializeToUndoBuilder(TOwner owner, UndoSnapshotBuilder builder) => - builder.WriteManagedReference(getter(owner) ?? throw new InvalidOperationException($"Cannot serialize a null value for {property.DeclaringType}.{propertyName} to the undo snapshot.")); - - public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader reader) => - setter(owner, reader.ReadOwnedReference(owner)); -} - +/// +/// With ValueSerializer<>, serializes and deserializes most collection +/// values (stored in non-writable properties). +/// +/// The type (not a ) that contains this property. +/// The declared type of this property, which implements +/// ICollection<> +/// The element type stored in the serialized collection. This type must be listed in +/// . +/// This property's . internal sealed class CollectionSerializer(PropertyInfo property) : PropertySerializer(property, PropertyType.Normal, false) where TCollection : ICollection where TOwner : class { @@ -243,6 +231,16 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader } } +/// +/// With ValueSerializer<>, serializes and deserializes +/// s (stored in non-writable properties). +/// +/// The type (not a ) that contains this property. +/// The declared type of this property, which is or derives from +/// ReadOnlyCollection<>. +/// The element type stored in the serialized collection. This type must be listed in +/// . +/// This property's . internal sealed class ReadOnlyCollectionSerializer(PropertyInfo property) : PropertySerializer(property, PropertyType.Normal, false) // This is ReadOnlyCollection, not IReadOnlyCollection, because we rely on knowing about the mutable backing storage of ReadOnlyCollection. @@ -302,6 +300,21 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader } } +/// +/// With ValueSerializer<> and +/// ValueSerializer<>, serializes and deserializes dictionary values (stored +/// in non-writable properties). +/// +/// The type (not a ) that contains this property. +/// The declared type of this property, which implements +/// IDictionary<, > +/// The type of the keys stored in the dictionary. This type must be listed in +/// and , and +/// ValueSerializer<>.Default +/// must have an overridden method. +/// The type of the values stored in the dictionary. This type must be listed in +/// . +/// This property's . internal class DictionarySerializer(PropertyInfo property) : PropertySerializer(property, PropertyType.Normal, false) where TCollection : IDictionary where TOwner : class { @@ -358,7 +371,7 @@ public override void DeserializeFromUndoBuilder(TOwner owner, UndoSnapshotReader 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, ValueSerializer.ReadFromUndoSnapshot(reader, owner)); } } } diff --git a/Yafc.Model/Serialization/SerializationMap.cs b/Yafc.Model/Serialization/SerializationMap.cs index e6d81aa6..9a50b7e3 100644 --- a/Yafc.Model/Serialization/SerializationMap.cs +++ b/Yafc.Model/Serialization/SerializationMap.cs @@ -5,6 +5,7 @@ using System.Reflection; using System.Text.Json; using Serilog; +using Yafc.I18n; using Yafc.UI; namespace Yafc.Model; @@ -19,10 +20,13 @@ public class NoUndoAttribute : Attribute { } public sealed class DeserializeWithNonPublicConstructorAttribute : Attribute { } /// -/// Represents a type that can be deserialized from JSON formats that do not match its current serialization format. This typically happens when an object changes -/// between value (string/float), object, and array representations. +/// Represents a type that can be deserialized from JSON formats that do not match its current serialization format. This typically happens when +/// an object changes between value (string/float), object, and array representations. /// /// The type that is being defined with custom deserialization rules. +/// When adding new static members to this interface, also add matching instance members to +/// and . Check that +/// helper is not and call the members of this interface using helper.MemberName. internal interface ICustomJsonDeserializer { /// /// Attempts to deserialize an object from custom (aka 'old') formats. @@ -32,8 +36,10 @@ internal interface ICustomJsonDeserializer { /// If the deserialization was successful (this method returns ), this is set to the result of reading the consumed JSON tokens. /// If unsuccessful, the output value of this parameter should not be used. /// when the custom deserialization was successful, and when the default serializer should be used instead. - // This is a static method so it can be declared for types that do no have a default constructor. + // This is a static method so it can be declared for types that do not have a default constructor. static abstract bool Deserialize(ref Utf8JsonReader reader, DeserializationContext context, out T? result); + + // When adding new static members to this interface, also add them to SerializationMap's nested types; see the remarks above. } internal abstract class SerializationMap { @@ -75,6 +81,7 @@ internal static class SerializationMap where T : class { private static readonly int constructorProperties; private static readonly ulong constructorFieldMask; private static readonly ulong requiredConstructorFieldMask; + private static readonly ICustomDeserializerHelper? helper; public class SpecificSerializationMap : SerializationMap { public override void BuildUndo(object? target, UndoSnapshotBuilder builder) { @@ -138,15 +145,11 @@ private static bool GetInterfaceSerializer(Type iface, [MaybeNullWhen(false)] ou 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; - } + keyType = args[0]; + elementType = args[1]; + if (ValueSerializer.IsKeySerializerSupported(keyType) && ValueSerializer.IsValueSerializerSupported(elementType)) { + serializerType = typeof(DictionarySerializer<,,,>); + return true; } } } @@ -157,6 +160,7 @@ private static bool GetInterfaceSerializer(Type iface, [MaybeNullWhen(false)] ou } static SerializationMap() { + List> list = []; bool isModel = typeof(ModelObject).IsAssignableFrom(typeof(T)); @@ -167,6 +171,13 @@ static SerializationMap() { constructor = typeof(T).GetConstructors(BindingFlags.Public | BindingFlags.Instance)[0]; } + if (typeof(ICustomJsonDeserializer).IsAssignableFrom(typeof(T))) { + // I want to call methods by using, e.g. `((ICustomJsonDeserializer)T).Deserialize(...)`, but that isn't supported. + // This helper calls ICustomJsonDeserializer.Deserialize (and any new methods) by the least obscure method I could come up with. + + helper = (ICustomDeserializerHelper)Activator.CreateInstance(typeof(CustomDeserializerHelper<>).MakeGenericType(typeof(T), typeof(T)))!; + } + var constructorParameters = constructor.GetParameters(); List processedProperties = []; @@ -219,9 +230,6 @@ static SerializationMap() { if (ValueSerializer.IsValueSerializerSupported(propertyType)) { serializerType = typeof(ValuePropertySerializer<,>); } - else if (typeof(ModelObject).IsAssignableFrom(propertyType)) { - serializerType = typeof(ReadWriteReferenceSerializer<,>); - } else { throw new NotSupportedException("Type " + typeof(T) + " has property " + property.Name + " that cannot be serialized"); } @@ -292,10 +300,19 @@ public static void SerializeToJson(T? value, Utf8JsonWriter writer) { return null; } + /// + /// A helper type for calling static members of . Static members there should have matching instance + /// members here. + /// private interface ICustomDeserializerHelper { bool Deserialize(ref Utf8JsonReader reader, DeserializationContext context, out T? deserializedObject); } + /// + /// A helper type for calling static members in . Static members there should have matching instance + /// members here, which call .MemberName and return the result, casting as appropriate. + /// + /// Always the same as , though the compiler doesn't know this. private class CustomDeserializerHelper : ICustomDeserializerHelper where U : ICustomJsonDeserializer { public bool Deserialize(ref Utf8JsonReader reader, DeserializationContext context, out T? deserializedObject) { bool result = U.Deserialize(ref reader, context, out U? intermediate); @@ -309,10 +326,7 @@ public bool Deserialize(ref Utf8JsonReader reader, DeserializationContext contex return null; } - if (typeof(ICustomJsonDeserializer).IsAssignableFrom(typeof(T))) { - // I want to do `((ICustomJsonDeserializer)T).Deserialize(...)`, but that isn't supported. - // This and its helper types call ICustomJsonDeserializer.Deserialize by the least obscure method I could come up with. - ICustomDeserializerHelper helper = (ICustomDeserializerHelper)Activator.CreateInstance(typeof(CustomDeserializerHelper<>).MakeGenericType(typeof(T), typeof(T)))!; + if (helper != null) { Utf8JsonReader savedState = reader; if (helper.Deserialize(ref reader, context, out T? result)) { // The custom deserializer was successful; return its result. @@ -378,7 +392,7 @@ public bool Deserialize(ref Utf8JsonReader reader, DeserializationContext contex return obj; } catch (Exception ex) { - context.Exception(ex, "Unable to deserialize " + typeof(T).Name, ErrorSeverity.MajorDataLoss); + context.Exception(ex, LSs.LoadErrorUnableToDeserializeUntranslated.L(typeof(T).Name), ErrorSeverity.MajorDataLoss); if (reader.TokenType == JsonTokenType.StartObject && reader.CurrentDepth == depth) { _ = reader.Read(); @@ -420,7 +434,7 @@ public static void PopulateFromJson(T obj, ref Utf8JsonReader reader, Deserializ property.DeserializeFromJson(obj, ref reader, allObjects); } catch (InvalidOperationException ex) { - allObjects.Exception(ex, "Encountered an unexpected value when reading the project file", ErrorSeverity.MajorDataLoss); + allObjects.Exception(ex, LSs.LoadErrorEncounteredUnexpectedValue, ErrorSeverity.MajorDataLoss); } } _ = reader.Read(); diff --git a/Yafc.Model/Serialization/ValueSerializers.cs b/Yafc.Model/Serialization/ValueSerializers.cs index 3497ab33..d2a1609b 100644 --- a/Yafc.Model/Serialization/ValueSerializers.cs +++ b/Yafc.Model/Serialization/ValueSerializers.cs @@ -2,11 +2,15 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.RegularExpressions; +using Yafc.I18n; namespace Yafc.Model; internal static class ValueSerializer { public static bool IsValueSerializerSupported(Type type) { + // Types listed in this method must have a corresponding ValueSerializer returned from CreateValueSerializer. + 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)) { @@ -21,22 +25,54 @@ public static bool IsValueSerializerSupported(Type type) { return true; } - if (type.IsClass && (typeof(ModelObject).IsAssignableFrom(type) || type.GetCustomAttribute() != null)) { + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IObjectWithQuality<>)) { return true; } - if (!type.IsClass && type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) { + if (type.IsClass && !type.IsAbstract && (typeof(ModelObject).IsAssignableFrom(type) || type.GetCustomAttribute() != null)) { + return true; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Nullable<>)) { return IsValueSerializerSupported(type.GetGenericArguments()[0]); } return false; } + + public static bool IsKeySerializerSupported(Type type) { + // Types listed in this method must have a ValueSerializer where GetJsonProperty is overridden. + + if (type == typeof(string) || type == typeof(Type) || type == typeof(Guid)) { + return true; + } + + if (typeof(FactorioObject).IsAssignableFrom(type)) { + return true; + } + + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IObjectWithQuality<>)) { + return true; + } + + return false; + } } +/// +/// The base class for serializing values that are [Serializable], native (e.g. , +/// ), or native-like (e.g. ). +/// +/// The type to be serialized/deserialized by this instance. internal abstract class ValueSerializer { + /// + /// Contains the serializer that should be used for . + /// public static readonly ValueSerializer Default = (ValueSerializer)CreateValueSerializer(); private static object CreateValueSerializer() { + // Types listed in this method must also return true from IsValueSerializerSupported. + if (typeof(T) == typeof(int)) { return new IntSerializer(); } @@ -71,36 +107,90 @@ private static object CreateValueSerializer() { // null-forgiving: Activator.CreateInstance only returns null for Nullable. // See System.Private.CoreLib\src\System\Activator.cs:20, e.g. https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Activator.cs#L20 + if (typeof(FactorioObject).IsAssignableFrom(typeof(T))) { + // `return new FactorioObjectSerializer();`, but in a format that works with the limitations of C#. + // The following blocks also create newly-constructed values with more restrictive constraints on T. return Activator.CreateInstance(typeof(FactorioObjectSerializer<>).MakeGenericType(typeof(T)))!; } + if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(IObjectWithQuality<>)) { + return Activator.CreateInstance(typeof(QualityObjectSerializer<>).MakeGenericType(typeof(T).GenericTypeArguments[0]))!; + } + if (typeof(T).IsEnum && typeof(T).GetEnumUnderlyingType() == typeof(int)) { return Activator.CreateInstance(typeof(EnumSerializer<>).MakeGenericType(typeof(T)))!; } - if (typeof(T).IsClass) { + if (typeof(T).IsClass && !typeof(T).IsAbstract) { if (typeof(ModelObject).IsAssignableFrom(typeof(T))) { return Activator.CreateInstance(typeof(ModelObjectSerializer<>).MakeGenericType(typeof(T)))!; } return Activator.CreateInstance(typeof(PlainClassesSerializer<>).MakeGenericType(typeof(T)))!; } - if (!typeof(T).IsClass && typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) { + + if (typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) { return Activator.CreateInstance(typeof(NullableSerializer<>).MakeGenericType(typeof(T).GetGenericArguments()[0]))!; } throw new InvalidOperationException($"No known serializer for {typeof(T)}."); } + /// + /// Reads an object from a project file. + /// + /// The that is reading the project file. On entry, this will point to the first token to + /// be read. On exit, this should point to the last token read. + /// The active , for reporting errors in the json data. + /// The object that owns this value. This is ignored except when reading s. + /// The object read from the file, or if . + /// == . public abstract T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner); + + /// + /// Writes the specified value to a project file. + /// + /// The that is writing to the project file + /// The value to be written to . public abstract void WriteToJson(Utf8JsonWriter writer, T? value); + /// + /// This converts from a value to a string, which can be used as the name of a json property. + /// Note: When overriding this method, also add a check in IsKeySerializerSupported, to allow the type to be used as a dictionary key. + /// + /// The value to convert to a string + /// , converted to a string that can read. public virtual string GetJsonProperty(T value) => throw new NotSupportedException("Using type " + typeof(T) + " as dictionary key is not supported"); + /// + /// Called to read a object from a project file when . == + /// . Instead of overriding this, it is usually better to write so it + /// can read tokens. + /// + /// The that is reading the project file. On entry, this will point to the property name + /// token to be read. On exit, this should still point to the property name token. + /// The active , for reporting errors in the json data. + /// The object that owns this value. This is ignored except when reading s. + /// The object read from the file. public virtual T? ReadFromJsonProperty(ref Utf8JsonReader reader, DeserializationContext context, object owner) => ReadFromJson(ref reader, context, owner); + /// + /// Called to read an object from an undo snapshot. The data to be read was written by . + /// + /// The that contains the objects to be read. + /// The object that owns this value. This is ignored except when reading s. + /// The object read from the snapshot. public abstract T? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner); + /// + /// Called to write an object to an undo snapshot. Write data in a format that can be read by . + /// + /// The that will store the objects. public abstract void WriteToUndoSnapshot(UndoSnapshotBuilder writer, T? value); + + /// + /// Called by other portions of the serialization system to determine whether values of type are allowed to be + /// . + /// public virtual bool CanBeNull => false; } @@ -226,7 +316,7 @@ internal class TypeSerializer : ValueSerializer { } Type? type = Type.GetType(s); if (type == null) { - context.Error("Type " + s + " does not exist. Possible plugin version change", ErrorSeverity.MinorDataLoss); + context.Error(LSs.LoadErrorUntranslatedTypeDoesNotExist.L(s), ErrorSeverity.MinorDataLoss); } return type; @@ -235,10 +325,6 @@ 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..]; - } writer.WriteStringValue(name); } @@ -248,13 +334,7 @@ public override string GetJsonProperty(Type value) { throw new ArgumentException($"value must be a type that has a FullName.", 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.")) { - name = "YAFC." + name[5..]; - } - return name; + return value.FullName; } public override Type? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) => reader.ReadManagedReference() as Type; @@ -308,11 +388,11 @@ internal class FactorioObjectSerializer : ValueSerializer where T : Factor 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); + context.Error(LSs.LoadErrorFluidHasIncorrectTemperature.L(t.locName), ErrorSeverity.MinorDataLoss); return t; } - context.Error("Factorio object '" + s + "' no longer exist. Check mods configuration.", ErrorSeverity.MinorDataLoss); + context.Error(LSs.LoadErrorUntranslatedFactorioObjectNotFound.L(s), ErrorSeverity.MinorDataLoss); } return obj as T; } @@ -334,6 +414,113 @@ public override void WriteToJson(Utf8JsonWriter writer, T? value) { public override bool CanBeNull => true; } +internal sealed partial class QualityObjectSerializer : ValueSerializer> where T : FactorioObject { + private static readonly ValueSerializer objectSerializer = ValueSerializer.Default; + private static readonly ValueSerializer qualitySerializer = ValueSerializer.Default; + + public override bool CanBeNull => true; + + public override IObjectWithQuality? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) { + if (reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName) { + string value = reader.GetString()!; + if (value.StartsWith('!')) { + // Read the new dictionary-key format, typically "!Item.coal!normal" + string separator = Separator().Match(value).Value; + string[] parts = value.Split(separator); + if (parts.Length == 3) { // The parts are , target.typeDotName, and quality.name + _ = Database.objectsByTypeName.TryGetValue(parts[1], out var obj); + _ = Database.objectsByTypeName.TryGetValue("Quality." + parts[2], out var qual); + if (obj is not T) { + context.Error(LSs.LoadErrorUntranslatedFactorioObjectNotFound.L(parts[1]), ErrorSeverity.MinorDataLoss); + } + else if (qual is not Quality quality) { + context.Error(LSs.LoadErrorUntranslatedFactorioQualityNotFound.L(parts[2]), ErrorSeverity.MinorDataLoss); + } + else { + return ObjectWithQuality.Get(obj as T, quality); + } + } + // If anything went wrong reading the dictionary-key format, try the non-quality format instead. + } + + // Read the old non-quality "Item.coal" format + return ObjectWithQuality.Get(objectSerializer.ReadFromJson(ref reader, context, owner), Quality.Normal); + } + + if (reader.TokenType == JsonTokenType.StartObject) { + // Read the object-based quality format: { "target": "Item.coal", "quality": "Quality.normal" } + reader.Read(); // { + reader.Read(); // "target": + T? obj = objectSerializer.ReadFromJson(ref reader, context, owner); + reader.Read(); // target + reader.Read(); // "quality": + Quality? quality = qualitySerializer.ReadFromJson(ref reader, context, owner); + reader.Read(); // quality + // If anything went wrong, the error has already been reported. We can just return null and continue. + return quality == null ? null : ObjectWithQuality.Get(obj, quality); + } + + if (reader.TokenType != JsonTokenType.Null) { + context.Error($"Unexpected token {reader.TokenType} reading a quality object.", ErrorSeverity.MinorDataLoss); + } + + return null; // Input is JsonTokenType.Null, or couldn't parse the input + } + + public override void WriteToJson(Utf8JsonWriter writer, IObjectWithQuality? value) { + if (value == null) { + writer.WriteNullValue(); + return; + } + + // TODO: This writes values that can be read by older versions of Yafc. For consistency with quality objects written as dictionary keys, + // replace these four lines with this after a suitable period of backwards compatibility: + // writer.WriteStringValue(GetJsonProperty(value)); + writer.WriteStartObject(); + writer.WriteString("target", value.target.typeDotName); + writer.WriteString("quality", value.quality.typeDotName); + writer.WriteEndObject(); + } + + public override string GetJsonProperty(IObjectWithQuality value) { + string target = value.target.typeDotName; + string quality = value.quality.name; + + // Construct an unambiguous separator that does not appear in the string data: Alternate ! and @ until (A) the separator does not appear + // in either the target or quality names, and (B) the first character of the object name cannot be interpreted as part of the separator. + // That is, if target is (somehow) "!target", separator cannot be "!@", because there would otherwise be no way to distinguish + // "!@" + "!target" from "!@!" + "target". + // Note that "!@!" + "@quality" is fine. Once we see that the initial "!@!" is not followed by '@', we know the separator is "!@!", and + // the @ in "@quality" must be part of the quality name. + // In reality, rule B should never trigger, since types do not start with ! or @. + // Even rule A will be rare, since most internal names do not contain '!'. + string separator = "!"; + while (true) { + if (target.Contains(separator) || quality.Contains(separator) || target.StartsWith('@')) { + separator += '@'; + } + else { + break; + } + if (target.Contains(separator) || quality.Contains(separator) || target.StartsWith('!')) { + separator += '!'; + } + else { + break; + } + } + + return separator + target + separator + quality; + } + + public override IObjectWithQuality? ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) + => (IObjectWithQuality?)reader.ReadManagedReference(); + public override void WriteToUndoSnapshot(UndoSnapshotBuilder writer, IObjectWithQuality? value) => writer.WriteManagedReference(value); + + [GeneratedRegex("^(!@)*!?")] + private static partial Regex Separator(); +} + internal class EnumSerializer : ValueSerializer where T : struct, Enum { public EnumSerializer() { if (Unsafe.SizeOf() != 4) { @@ -360,6 +547,27 @@ public override T ReadFromUndoSnapshot(UndoSnapshotReader reader, object owner) /// Serializes classes marked with [Serializable], except blueprint classes, s, and s. /// internal class PlainClassesSerializer : ValueSerializer where T : class { + static PlainClassesSerializer() { + // The checks in CreateValueSerializer should prevent these two from happening. + if (typeof(T).IsAssignableTo(typeof(ModelObject))) { + throw new InvalidOperationException($"PlainClassesSerializer should not be used for {typeof(T)} because it is derived from ModelObject."); + } + if (typeof(T).IsAssignableTo(typeof(FactorioObject))) { + throw new InvalidOperationException($"PlainClassesSerializer should not be used for {typeof(T)} because it is derived from FactorioObject."); + } + if (!typeof(T).FullName!.StartsWith("Yafc.")) { + // Well, probably. It's unlikely this default serialization will correctly handle types that were not created with it in mind. + throw new InvalidOperationException($"PlainClassesSerializer should not be used for {typeof(T)} because it is outside the Yafc namespace."); + } + if (typeof(T).GetCustomAttribute() == null) { + // If you want standard serialization behavior, like BeaconConfiguration, add the [Serializable] attribute to the type instead of + // explicitly listing the type in IsValueSerializerSupported. + // If you want non-standard serialization behavior, like FactorioObject, add a check for this type in CreateValueSerializer, and + // return a custom serializer. + throw new InvalidOperationException($"PlainClassesSerializer should not be used for {typeof(T)} because it does not have a [Serializable] attribute."); + } + } + private static readonly SerializationMap builder = SerializationMap.GetSerializationMap(typeof(T)); public override T? ReadFromJson(ref Utf8JsonReader reader, DeserializationContext context, object? owner) => SerializationMap.DeserializeFromJson(null, ref reader, context); diff --git a/Yafc.Model/Yafc.Model.csproj b/Yafc.Model/Yafc.Model.csproj index 2edc61a2..d5251ad6 100644 --- a/Yafc.Model/Yafc.Model.csproj +++ b/Yafc.Model/Yafc.Model.csproj @@ -9,6 +9,7 @@ + diff --git a/Yafc.Parser/Data/FactorioDataDeserializer.cs b/Yafc.Parser/Data/FactorioDataDeserializer.cs index 111a50fc..d422d5e1 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using SDL2; using Serilog; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -75,7 +76,7 @@ private void UpdateSplitFluids() { } fluid.variants.Sort(DataUtils.FluidTemperatureComparer); - fluidVariants[fluid.type + "." + fluid.name] = fluid.variants; + fluidVariants[fluid.typeDotName] = fluid.variants; foreach (var variant in fluid.variants) { AddTemperatureToFluidIcon(variant); @@ -113,7 +114,7 @@ private static void AddTemperatureToFluidIcon(Fluid fluid) { public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, bool netProduction, IProgress<(string, string)> progress, ErrorCollector errorCollector, bool renderIcons, bool useLatestSave) { - progress.Report(("Loading", "Loading items")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingItems)); 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)); @@ -126,25 +127,25 @@ public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, } allModules.AddRange(allObjects.OfType()); - progress.Report(("Loading", "Loading tiles")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingTiles)); DeserializePrototypes(raw, "tile", DeserializeTile, progress, errorCollector); - progress.Report(("Loading", "Loading fluids")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingFluids)); DeserializePrototypes(raw, "fluid", DeserializeFluid, progress, errorCollector); - progress.Report(("Loading", "Loading recipes")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingRecipes)); DeserializePrototypes(raw, "recipe", DeserializeRecipe, progress, errorCollector); - progress.Report(("Loading", "Loading locations")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingLocations)); DeserializePrototypes(raw, "planet", DeserializeLocation, progress, errorCollector); DeserializePrototypes(raw, "space-location", DeserializeLocation, progress, errorCollector); rootAccessible.Add(GetObject("nauvis")); - progress.Report(("Loading", "Loading technologies")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingTechnologies)); DeserializePrototypes(raw, "technology", DeserializeTechnology, progress, errorCollector); - progress.Report(("Loading", "Loading qualities")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingQualities)); DeserializePrototypes(raw, "quality", DeserializeQuality, progress, errorCollector); Quality.Normal = GetObject("normal"); rootAccessible.Add(Quality.Normal); DeserializePrototypes(raw, "asteroid-chunk", DeserializeAsteroidChunk, progress, errorCollector); - progress.Report(("Loading", "Loading entities")); + progress.Report((LSs.ProgressLoading, LSs.ProgressLoadingEntities)); LuaTable entityPrototypes = (LuaTable?)prototypes["entity"] ?? throw new ArgumentException("Could not load prototypes.entity from data argument", nameof(prototypes)); foreach (object prototypeName in entityPrototypes.ObjectElements.Keys) { DeserializePrototypes(raw, (string)prototypeName, DeserializeEntity, progress, errorCollector); @@ -153,7 +154,7 @@ public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, ParseCaptureEffects(); ParseModYafcHandles(data["script_enabled"] as LuaTable); - progress.Report(("Post-processing", "Computing maps")); + progress.Report((LSs.ProgressPostprocessing, LSs.ProgressComputingMaps)); // Deterministically sort all objects allObjects.Sort((a, b) => a.sortingOrder == b.sortingOrder ? string.Compare(a.typeDotName, b.typeDotName, StringComparison.Ordinal) : a.sortingOrder - b.sortingOrder); @@ -169,14 +170,15 @@ public Project LoadData(string projectPath, LuaTable data, LuaTable prototypes, var iconRenderTask = renderIcons ? Task.Run(RenderIcons) : Task.CompletedTask; UpdateRecipeCatalysts(); CalculateItemWeights(); + ObjectWithQuality.LoadCache(allObjects); ExportBuiltData(); - progress.Report(("Post-processing", "Calculating dependencies")); + progress.Report((LSs.ProgressPostprocessing, LSs.ProgressCalculatingDependencies)); Dependencies.Calculate(); TechnologyLoopsFinder.FindTechnologyLoops(); - progress.Report(("Post-processing", "Creating project")); + progress.Report((LSs.ProgressPostprocessing, LSs.ProgressCreatingProject)); Project project = Project.ReadFromFile(projectPath, errorCollector, useLatestSave); Analysis.ProcessAnalyses(progress, project, errorCollector); - progress.Report(("Rendering icons", "")); + progress.Report((LSs.ProgressRenderingIcons, "")); iconRenderedProgress = progress; iconRenderTask.Wait(); @@ -204,7 +206,7 @@ private void RenderIcons() { foreach (var o in allObjects) { if (++rendered % 100 == 0) { - iconRenderedProgress?.Report(("Rendering icons", $"{rendered}/{allObjects.Count}")); + iconRenderedProgress?.Report((LSs.ProgressRenderingIcons, LSs.ProgressRenderingXOfY.L(rendered, allObjects.Count))); } if (o.iconSpec != null && o.iconSpec.Length > 0) { @@ -317,7 +319,7 @@ private static void DeserializePrototypes(LuaTable data, string type, Action progress, ErrorCollector errorCollector) { object? table = data[type]; - progress.Report(("Building objects", type)); + progress.Report((LSs.ProgressBuildingObjects, type)); if (table is not LuaTable luaTable) { return; @@ -424,7 +426,7 @@ void readTrigger(LuaTable table) { item.stackSize = table.Get("stack_size", 1); if (item.locName == null && table.Get("placed_as_equipment_result", out string? result)) { - item.locName = LocalisedStringParser.Parse("equipment-name." + result, [])!; + item.locName = LocalisedStringParser.ParseKey("equipment-name." + result, [])!; } if (table.Get("fuel_value", out string? fuelValue)) { item.fuelValue = ParseEnergy(fuelValue); @@ -456,7 +458,7 @@ void readTrigger(LuaTable table) { } if (GetRef(table, "spoil_result", out Item? spoiled)) { - var recipe = CreateSpecialRecipe(item, SpecialNames.SpoilRecipe, "spoiling"); + var recipe = CreateSpecialRecipe(item, SpecialNames.SpoilRecipe, LSs.SpecialRecipeSpoiling); recipe.ingredients = [new Ingredient(item, 1)]; recipe.products = [new Product(spoiled, 1)]; recipe.time = table.Get("spoil_ticks", 0) / 60f; @@ -558,11 +560,8 @@ private void CalculateItemWeights() { nextWeightCalculation:; } - List rocketSilos = [.. registeredObjects.Values.OfType().Where(e => e.factorioType == "rocket-silo")]; - int maxStacks = 1;// if we have no rocket silos, default to one stack. - if (rocketSilos.Count > 0) { - maxStacks = rocketSilos.Max(r => r.rocketInventorySize); - } + int maxStacks = registeredObjects.Values.OfType().Where(e => e.factorioType == "rocket-silo").MaxBy(e => e.rocketInventorySize)?.rocketInventorySize + ?? 1; // if we have no rocket silos, default to one stack. foreach (Item item in allObjects.OfType()) { // If it doesn't otherwise have a weight, it gets the default weight. @@ -570,11 +569,16 @@ private void CalculateItemWeights() { item.weight = defaultItemWeight; } + // The item count is initialized to 1, but it should be the rocket capacity. Scale up the ingredient and product(s). + // item.weight == 0 is possible if defaultItemWeight is 0, so we bail out on the / item.weight in that case. + int maxFactor = maxStacks * item.stackSize; + int factor = item.weight == 0 ? maxFactor : Math.Min(rocketCapacity / item.weight, maxFactor); + + item.rocketCapacity = factor; + if (registeredObjects.TryGetValue((typeof(Mechanics), SpecialNames.RocketLaunch + "." + item.name), out FactorioObject? r) && r is Mechanics recipe) { - // The item count is initialized to 1, but it should be the rocket capacity. Scale up the ingredient and product(s). - int factor = Math.Min(rocketCapacity / item.weight, maxStacks * item.stackSize); recipe.ingredients[0] = new(item, factor); for (int i = 0; i < recipe.products.Length; i++) { recipe.products[i] *= factor; @@ -590,7 +594,7 @@ private void CalculateItemWeights() { /// The result of launching , if known. Otherwise , to preserve /// the existing launch products of a preexisting recipe, or set no products for a new recipe. private void EnsureLaunchRecipe(Item item, Product[]? launchProducts) { - Recipe recipe = CreateSpecialRecipe(item, SpecialNames.RocketLaunch, "launched"); + Recipe recipe = CreateSpecialRecipe(item, SpecialNames.RocketLaunch, LSs.SpecialRecipeLaunched); recipe.ingredients = [ // When this is called, we don't know the item weight or the rocket capacity. @@ -626,7 +630,7 @@ private void DeserializeTile(LuaTable table, ErrorCollector _) { tile.Fluid = pumpingFluid; string recipeCategory = SpecialNames.PumpingRecipe + "tile"; - Recipe recipe = CreateSpecialRecipe(pumpingFluid, recipeCategory, "pumping"); + Recipe recipe = CreateSpecialRecipe(pumpingFluid, recipeCategory, LSs.SpecialRecipePumping); if (recipe.products == null) { recipe.products = [new Product(pumpingFluid, 1200f)]; // set to Factorio default pump amounts - looks nice in tooltip @@ -709,17 +713,17 @@ private void DeserializeLocation(LuaTable table, ErrorCollector collector) { target.factorioType = table.Get("type", ""); if (table.Get("localised_name", out object? loc)) { // Keep UK spelling for Factorio/LUA data objects - target.locName = LocalisedStringParser.Parse(loc)!; + target.locName = LocalisedStringParser.ParseObject(loc)!; } else { - target.locName = LocalisedStringParser.Parse(prototypeType + "-name." + target.name, [])!; + target.locName = LocalisedStringParser.ParseKey(prototypeType + "-name." + target.name, [])!; } if (table.Get("localised_description", out loc)) { // Keep UK spelling for Factorio/LUA data objects - target.locDescr = LocalisedStringParser.Parse(loc); + target.locDescr = LocalisedStringParser.ParseObject(loc); } else { - target.locDescr = LocalisedStringParser.Parse(prototypeType + "-description." + target.name, []); + target.locDescr = LocalisedStringParser.ParseKey(prototypeType + "-description." + target.name, []); } _ = table.Get("icon_size", out float defaultIconSize); diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs b/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs index 8ec0c6e4..7ed8bded 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_Context.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Yafc.I18n; using Yafc.Model; namespace Yafc.Parser; @@ -72,31 +73,31 @@ Item createSpecialItem(string name, string locName, string locDescr, string icon return obj; } - electricity = createSpecialObject(true, SpecialNames.Electricity, "Electricity", "This is an object that represents electric energy", + electricity = createSpecialObject(true, SpecialNames.Electricity, LSs.SpecialObjectElectricity, LSs.SpecialObjectElectricityDescription, "__core__/graphics/icons/alerts/electricity-icon-unplugged.png", "signal-E"); - heat = createSpecialObject(true, SpecialNames.Heat, "Heat", "This is an object that represents heat energy", "__core__/graphics/arrows/heat-exchange-indication.png", "signal-H"); + heat = createSpecialObject(true, SpecialNames.Heat, LSs.SpecialObjectHeat, LSs.SpecialObjectHeatDescription, "__core__/graphics/arrows/heat-exchange-indication.png", "signal-H"); - voidEnergy = createSpecialObject(true, SpecialNames.Void, "Void", "This is an object that represents infinite energy", "__core__/graphics/icons/mip/infinity.png", "signal-V"); + voidEnergy = createSpecialObject(true, SpecialNames.Void, LSs.SpecialObjectVoid, LSs.SpecialObjectVoidDescription, "__core__/graphics/icons/mip/infinity.png", "signal-V"); voidEnergy.isVoid = true; voidEnergy.isLinkable = false; voidEnergy.showInExplorers = false; 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/rocket-static-pod.png", "signal-R"); + rocketLaunch = createSpecialObject(false, SpecialNames.RocketLaunch, LSs.SpecialObjectLaunchSlot, + LSs.SpecialObjectLaunchSlotDescription, "__base__/graphics/entity/rocket-silo/rocket-static-pod.png", "signal-R"); science = GetObject("science"); science.showInExplorers = false; Analysis.ExcludeFromAnalysis(science); formerAliases["Special.research-unit"] = science; - generatorProduction = CreateSpecialRecipe(electricity, SpecialNames.GeneratorRecipe, "generating"); + generatorProduction = CreateSpecialRecipe(electricity, SpecialNames.GeneratorRecipe, LSs.SpecialRecipeGenerating); generatorProduction.products = [new Product(electricity, 1f)]; generatorProduction.flags |= RecipeFlags.ScaleProductionWithPower; generatorProduction.ingredients = []; - reactorProduction = CreateSpecialRecipe(heat, SpecialNames.ReactorRecipe, "generating"); + reactorProduction = CreateSpecialRecipe(heat, SpecialNames.ReactorRecipe, LSs.SpecialRecipeGenerating); reactorProduction.products = [new Product(heat, 1f)]; reactorProduction.flags |= RecipeFlags.ScaleProductionWithPower; reactorProduction.ingredients = []; @@ -105,8 +106,8 @@ Item createSpecialItem(string name, string locName, string locDescr, string icon laborEntityEnergy = new EntityEnergy { type = EntityEnergyType.Labor, effectivity = float.PositiveInfinity }; // Note: These must be Items (or possibly a derived type) so belt capacity can be displayed and set. - totalItemInput = createSpecialItem("item-total-input", "Total item consumption", "This item represents the combined total item input of a multi-ingredient recipe. It can be used to set or measure the number of sushi belts required to supply this recipe row.", "__base__/graphics/icons/signal/signal_I.png"); - totalItemOutput = createSpecialItem("item-total-output", "Total item production", "This item represents the combined total item output of a multi-product recipe. It can be used to set or measure the number of sushi belts required to handle the products of this recipe row.", "__base__/graphics/icons/signal/signal_O.png"); + totalItemInput = createSpecialItem("item-total-input", LSs.SpecialItemTotalConsumption, LSs.SpecialItemTotalConsumptionDescription, "__base__/graphics/icons/signal/signal_I.png"); + totalItemOutput = createSpecialItem("item-total-output", LSs.SpecialItemTotalProduction, LSs.SpecialItemTotalProductionDescription, "__base__/graphics/icons/signal/signal_O.png"); formerAliases["Special.total-item-input"] = totalItemInput; formerAliases["Special.total-item-output"] = totalItemOutput; } @@ -189,13 +190,13 @@ private void ExportBuiltData() { } Database.allSciencePacks = [.. sciencePacks]; - Database.voidEnergy = new(voidEnergy, Quality.Normal); - Database.science = new(science, Quality.Normal); - Database.itemInput = new(totalItemInput, Quality.Normal); - Database.itemOutput = new(totalItemOutput, Quality.Normal); - Database.electricity = new(electricity, Quality.Normal); - Database.electricityGeneration = new(generatorProduction, Quality.Normal); - Database.heat = new(heat, Quality.Normal); + Database.voidEnergy = voidEnergy.With(Quality.Normal); + Database.science = science.With(Quality.Normal); + Database.itemInput = totalItemInput.With(Quality.Normal); + Database.itemOutput = totalItemOutput.With(Quality.Normal); + Database.electricity = electricity.With(Quality.Normal); + Database.electricityGeneration = generatorProduction.With(Quality.Normal); + Database.heat = heat.With(Quality.Normal); Database.character = character; int firstSpecial = 0; int firstItem = Skip(firstSpecial, FactorioObjectSortOrder.SpecialGoods); @@ -471,7 +472,7 @@ private void CalculateMaps(bool netProduction) { switch (o) { case RecipeOrTechnology recipeOrTechnology: if (recipeOrTechnology is Recipe recipe) { - recipe.FallbackLocalization(recipe.mainProduct, "A recipe to create"); + recipe.FallbackLocalization(recipe.mainProduct, LSs.LocalizationFallbackDescriptionRecipeToCreate); recipe.technologyUnlock = recipeUnlockers.GetArray(recipe); } @@ -484,16 +485,15 @@ private void CalculateMaps(bool netProduction) { if (o is Item item) { if (item.placeResult != null) { - item.FallbackLocalization(item.placeResult, "An item to build"); + item.FallbackLocalization(item.placeResult, LSs.LocalizationFallbackDescriptionItemToBuild); } } else if (o is Fluid fluid && fluid.variants != null) { - string temperatureDescr = "Temperature: " + fluid.temperature + "°"; if (fluid.locDescr == null) { - fluid.locDescr = temperatureDescr; + fluid.locDescr = LSs.FluidDescriptionTemperatureSolo.L(fluid.temperature); } else { - fluid.locDescr = temperatureDescr + "\n" + fluid.locDescr; + fluid.locDescr = LSs.FluidDescriptionTemperatureAdded.L(fluid.temperature, fluid.locDescr); } } @@ -560,7 +560,7 @@ private void CalculateMaps(bool netProduction) { } foreach (var mechanic in allMechanics) { - mechanic.locName = mechanic.source.locName + " " + mechanic.locName; + mechanic.locName = mechanic.localizationKey.Localize(mechanic.source.locName, mechanic.products.FirstOrDefault()?.goods.fluid?.temperature!); mechanic.locDescr = mechanic.source.locDescr; mechanic.iconSpec = mechanic.source.iconSpec; } @@ -643,7 +643,7 @@ private void CalculateMaps(bool netProduction) { foreach (var (_, list) in fluidVariants) { foreach (var fluid in list) { - fluid.locName += " " + fluid.temperature + "°"; + fluid.locName = LSs.FluidNameWithTemperature.L(fluid.locName, fluid.temperature); } } @@ -654,7 +654,7 @@ static int countNonDsrRecipes(IEnumerable recipes) && r.specialType is not FactorioObjectSpecialType.Recycling and not FactorioObjectSpecialType.Voiding); } - private Recipe CreateSpecialRecipe(FactorioObject production, string category, string hint) { + private Recipe CreateSpecialRecipe(FactorioObject production, string category, LocalizableString specialRecipeKey) { string fullName = category + (category.EndsWith('.') ? "" : ".") + production.name; if (registeredObjects.TryGetValue((typeof(Mechanics), fullName), out var recipeRaw)) { @@ -666,7 +666,7 @@ private Recipe CreateSpecialRecipe(FactorioObject production, string category, s recipe.factorioType = SpecialNames.FakeRecipe; recipe.name = fullName; recipe.source = production; - recipe.locName = hint; + recipe.localizationKey = specialRecipeKey; recipe.enabled = true; recipe.hidden = true; recipe.technologyUnlock = []; diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs index 98a2e59f..5b789665 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_Entity.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -152,7 +153,7 @@ private static void ParseModules(LuaTable table, EntityWithModules entity, Allow private Recipe CreateLaunchRecipe(EntityCrafter entity, Recipe recipe, int partsRequired, int outputCount) { string launchCategory = SpecialNames.RocketCraft + entity.name; - var launchRecipe = CreateSpecialRecipe(recipe, launchCategory, "launch"); + var launchRecipe = CreateSpecialRecipe(recipe, launchCategory, LSs.SpecialRecipeLaunch); recipeCrafters.Add(entity, launchCategory); launchRecipe.ingredients = [.. recipe.products.Select(x => new Ingredient(x.goods, x.amount * partsRequired))]; launchRecipe.products = [new Product(rocketLaunch, outputCount)]; @@ -265,7 +266,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { // otherwise convert boiler production to a recipe string category = SpecialNames.BoilerRecipe + boiler.name; - var recipe = CreateSpecialRecipe(output, category, "boiling to " + targetTemp + "°"); + var recipe = CreateSpecialRecipe(output, category, LSs.SpecialRecipeBoiling); recipeCrafters.Add(boiler, category); recipe.flags |= RecipeFlags.UsesFluidTemperature; // TODO: input fluid amount now depends on its temperature, using min temperature should be OK for non-modded @@ -466,7 +467,7 @@ private void DeserializeEntity(LuaTable table, ErrorCollector errorCollector) { if (table.Get("fluid_box", out LuaTable? fluidBox) && fluidBox.Get("fluid", out string? fluidName)) { var pumpingFluid = GetFluidFixedTemp(fluidName, 0); string recipeCategory = SpecialNames.PumpingRecipe + pumpingFluid.name; - recipe = CreateSpecialRecipe(pumpingFluid, recipeCategory, "pumping"); + recipe = CreateSpecialRecipe(pumpingFluid, recipeCategory, LSs.SpecialRecipePumping); recipeCrafters.Add(pump, recipeCategory); pump.energy = voidEntityEnergy; @@ -545,7 +546,7 @@ void parseEffect(LuaTable effect) { if (factorioType == "resource") { // mining resource is processed as a recipe _ = table.Get("category", out string category, "basic-solid"); - var recipe = CreateSpecialRecipe(entity, SpecialNames.MiningRecipe + category, "mining"); + var recipe = CreateSpecialRecipe(entity, SpecialNames.MiningRecipe + category, LSs.SpecialRecipeMining); recipe.flags = RecipeFlags.UsesMiningProductivity; recipe.time = minable.Get("mining_time", 1f); recipe.products = products; @@ -568,7 +569,7 @@ void parseEffect(LuaTable effect) { else if (factorioType == "plant") { // harvesting plants is processed as a recipe foreach (var seed in plantResults.Where(x => x.Value == name).Select(x => x.Key)) { - var recipe = CreateSpecialRecipe(seed, SpecialNames.PlantRecipe, "planting"); + var recipe = CreateSpecialRecipe(seed, SpecialNames.PlantRecipe, LSs.SpecialRecipePlanting); recipe.time = table.Get("growth_ticks", 0) / 60f; recipe.ingredients = [new Ingredient(seed, 1)]; recipe.products = products; @@ -642,7 +643,7 @@ private void DeserializeAsteroidChunk(LuaTable table, ErrorCollector errorCollec Entity chunk = DeserializeCommon(table, "asteroid-chunk"); Item asteroid = GetObject(chunk.name); if (asteroid.showInExplorers) { // don't create mining recipes for parameter chunks. - Recipe recipe = CreateSpecialRecipe(asteroid, SpecialNames.AsteroidCapture, "mining"); + Recipe recipe = CreateSpecialRecipe(asteroid, SpecialNames.AsteroidCapture, LSs.SpecialRecipeMining); recipe.time = 1; recipe.ingredients = []; recipe.products = [new Product(asteroid, 1)]; diff --git a/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs b/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs index cfea2ae1..221c7ca3 100644 --- a/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs +++ b/Yafc.Parser/Data/FactorioDataDeserializer_RecipeAndTechnology.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using Yafc.I18n; using Yafc.Model; namespace Yafc.Parser; @@ -56,13 +57,15 @@ private void DeserializeQuality(LuaTable table, ErrorCollector errorCollector) { } private void UpdateRecipeCatalysts() { - foreach (var recipe in allObjects.OfType()) { - foreach (var product in recipe.products) { - if (product.productivityAmount == product.amount) { - float catalyst = recipe.GetConsumptionPerRecipe(product.goods); - - if (catalyst > 0f) { - product.SetCatalyst(catalyst); + if (factorioVersion < new Version(2, 0, 0)) { + foreach (var recipe in allObjects.OfType()) { + foreach (var product in recipe.products) { + if (product.productivityAmount == product.amount) { + float catalyst = recipe.GetConsumptionPerRecipe(product.goods); + + if (catalyst > 0f) { + product.SetCatalyst(catalyst); + } } } } @@ -152,10 +155,11 @@ private void LoadTechnologyData(Technology technology, LuaTable table, ErrorColl continue; } - float change = modifier.Get("change", 0f); + _ = technology.changeRecipeProductivity.TryGetValue(recipe, out float change); + change += modifier.Get("change", 0f); - technology.changeRecipeProductivity.Add(recipe, change); - recipe.technologyProductivity.Add(technology, change); + technology.changeRecipeProductivity[recipe] = change; + recipe.technologyProductivity[technology] = change; break; } @@ -351,7 +355,7 @@ private void LoadResearchTrigger(LuaTable researchTriggerTable, ref Technology t EnsureLaunchRecipe(item, null); break; default: - errorCollector.Error($"Research trigger of {technology.typeDotName} has an unsupported type {type}", ErrorSeverity.MinorDataLoss); + errorCollector.Error(LSs.ResearchHasAnUnsupportedTriggerType.L(technology.typeDotName, type), ErrorSeverity.MinorDataLoss); break; } } @@ -372,5 +376,6 @@ private void LoadRecipeData(Recipe recipe, LuaTable table, ErrorCollector errorC recipe.hidden = table.Get("hidden", false); recipe.enabled = table.Get("enabled", true); + recipe.maximumProductivity = table.Get("maximum_productivity", 3f); } } diff --git a/Yafc.Parser/FactorioDataSource.cs b/Yafc.Parser/FactorioDataSource.cs index 8876f872..50be04a2 100644 --- a/Yafc.Parser/FactorioDataSource.cs +++ b/Yafc.Parser/FactorioDataSource.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Text.RegularExpressions; using Serilog; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -121,12 +122,22 @@ private static void LoadModLocale(string modName, string locale) { } } + public static void LoadYafcLocale(string locale) { + try { + foreach (string localeName in Directory.EnumerateFiles("Data/locale/" + locale + "/")) { + using Stream stream = File.Open(localeName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + FactorioLocalization.Parse(stream); + } + } + catch (DirectoryNotFoundException) { /* No Yafc translation for this 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)); + progress.Report((LSs.ProgressInitializing, entry)); ModInfo info = new(entry, File.ReadAllBytes(infoFile)); mods.Add(info); } @@ -179,13 +190,13 @@ public static Project Parse(string factorioPath, string modPath, string projectP try { CurrentLoadingMod = null; string modSettingsPath = Path.Combine(modPath, "mod-settings.dat"); - progress.Report(("Initializing", "Loading mod list")); + progress.Report((LSs.ProgressInitializing, LSs.ProgressLoadingModList)); string modListPath = Path.Combine(modPath, "mod-list.json"); Dictionary versionSpecifiers = []; bool hasModList = File.Exists(modListPath); if (hasModList) { - var mods = JsonSerializer.Deserialize(File.ReadAllText(modListPath)) ?? throw new($"Could not read mod list from {modListPath}"); + var mods = JsonSerializer.Deserialize(File.ReadAllText(modListPath)) ?? throw new(LSs.CouldNotReadModList.L(modListPath)); allMods = mods.mods.Where(x => x.enabled).Select(x => x.name).ToDictionary(x => x, x => (ModInfo)null!); versionSpecifiers = mods.mods.Where(x => x.enabled && !string.IsNullOrEmpty(x.version)).ToDictionary(x => x.name, x => Version.Parse(x.version!)); // null-forgiving: null version strings are filtered by the Where. } @@ -235,7 +246,12 @@ 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)) { + ModInfo? existing = null; + bool modFound = mod.ValidForFactorioVersion(factorioVersion) && allMods.TryGetValue(mod.name, out existing); + bool higherVersionOrFolder = existing == null || mod.parsedVersion > existing.parsedVersion || (mod.parsedVersion == existing.parsedVersion && existing.zipArchive != null && mod.zipArchive == null); + bool existingMatchesVersionDirective = versionSpecifiers.TryGetValue(mod.name, out var version) && existing?.parsedVersion == version; + + if (modFound && higherVersionOrFolder && !existingMatchesVersionDirective) { existing?.Dispose(); allMods[mod.name] = mod; } @@ -258,7 +274,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP } if (missingMod != null) { - throw new NotSupportedException("Mod not found: " + missingMod + ". Try loading this pack in Factorio first."); + throw new NotSupportedException(LSs.ModNotFoundTryInFactorio.L(missingMod)); } List modsToDisable = []; @@ -282,7 +298,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP } while (modsToDisable.Count > 0); CurrentLoadingMod = null; - progress.Report(("Initializing", "Creating Lua context")); + progress.Report((LSs.ProgressInitializing, LSs.ProgressCreatingLuaContext)); HashSet modsToLoad = [.. allMods.Keys]; string[] modLoadOrder = new string[modsToLoad.Count]; @@ -302,7 +318,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP } } if (currentLoadBatch.Count == 0) { - throw new NotSupportedException("Mods dependencies are circular. Unable to load mods: " + string.Join(", ", modsToLoad)); + throw new NotSupportedException(LSs.CircularModDependencies.L(string.Join(LSs.ListSeparator, modsToLoad))); } foreach (string mod in currentLoadBatch) { @@ -360,7 +376,7 @@ public static Project Parse(string factorioPath, string modPath, string projectP FactorioDataDeserializer deserializer = new FactorioDataDeserializer(factorioVersion ?? defaultFactorioVersion); var project = deserializer.LoadData(projectPath, dataContext.data, (LuaTable)dataContext.defines["prototypes"]!, netProduction, progress, errorCollector, renderIcons, useLatestSave); logger.Information("Completed!"); - progress.Report(("Completed!", "")); + progress.Report((LSs.ProgressCompleted, "")); return project; } diff --git a/Yafc.Parser/LuaContext.cs b/Yafc.Parser/LuaContext.cs index a662e8ca..db33bea7 100644 --- a/Yafc.Parser/LuaContext.cs +++ b/Yafc.Parser/LuaContext.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Runtime.InteropServices; using System.Text; using Serilog; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Yafc.Model.Tests")] @@ -549,7 +551,7 @@ public void Dispose() { } public void DoModFiles(string[] modorder, string fileName, IProgress<(string, string)> progress) { - string header = "Executing mods " + fileName; + string header = LSs.ProgressExecutingModAtDataStage.L(fileName); foreach (string mod in modorder) { required.Clear(); @@ -570,7 +572,7 @@ public void DoModFiles(string[] modorder, string fileName, IProgress<(string, st public LuaTable defines => (LuaTable)GetGlobal("defines")!; } -internal class LuaTable { +internal class LuaTable : ILocalizable { public readonly LuaContext context; public readonly int refId; @@ -590,4 +592,9 @@ public object? this[string index] { public List ArrayElements => context.ArrayElements(refId); public Dictionary ObjectElements => context.ObjectElements(refId); + + bool ILocalizable.Get([NotNullWhen(true)] out string? key, out object[] parameters) { + parameters = ArrayElements.Skip(1).ToArray()!; + return this.Get(1, out key); + } } diff --git a/Yafc.Parser/Yafc.Parser.csproj b/Yafc.Parser/Yafc.Parser.csproj index 6a400f83..ac0dbd2d 100644 --- a/Yafc.Parser/Yafc.Parser.csproj +++ b/Yafc.Parser/Yafc.Parser.csproj @@ -20,6 +20,7 @@ + diff --git a/Yafc.UI/Core/ExceptionScreen.cs b/Yafc.UI/Core/ExceptionScreen.cs index b97c101d..59b6d871 100644 --- a/Yafc.UI/Core/ExceptionScreen.cs +++ b/Yafc.UI/Core/ExceptionScreen.cs @@ -1,6 +1,7 @@ using System; using SDL2; using Serilog; +using Yafc.I18n; namespace Yafc.UI; @@ -41,15 +42,15 @@ protected override void BuildContents(ImGui gui) { gui.BuildText(ex.StackTrace, TextBlockDisplayStyle.WrappedText); using (gui.EnterRow(0.5f, RectAllocator.RightRow)) { - if (gui.BuildButton("Close")) { + if (gui.BuildButton(LSs.Close)) { Close(); } - if (gui.BuildButton("Ignore future errors", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.ExceptionIgnoreFutureErrors, SchemeColor.Grey)) { ignoreAll = true; Close(); } - if (gui.BuildButton("Copy to clipboard", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.CopyToClipboard, SchemeColor.Grey)) { _ = SDL.SDL_SetClipboardText(ex.Message + "\n\n" + ex.StackTrace); } } diff --git a/Yafc.UI/ImGui/ImGuiBuilding.cs b/Yafc.UI/ImGui/ImGuiBuilding.cs index 0db59678..c8e7c5f1 100644 --- a/Yafc.UI/ImGui/ImGuiBuilding.cs +++ b/Yafc.UI/ImGui/ImGuiBuilding.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Numerics; using SDL2; @@ -139,6 +140,9 @@ public Rect AllocateTextRect(out TextCache? cache, string? text, TextBlockDispla rect = AllocateRect(0f, topOffset + (fontSize.lineSize / pixelsPerUnit)); } else { + if (Debugger.IsAttached && text.Contains("Key not found: ")) { + Debugger.Break(); // drawing improperly internationalized text + } Vector2 textSize = GetTextDimensions(out cache, text, displayStyle.Font, displayStyle.WrapText, maxWidth); rect = AllocateRect(textSize.X, topOffset + (textSize.Y), displayStyle.Alignment); } diff --git a/Yafc.UI/ImGui/ImGuiUtils.cs b/Yafc.UI/ImGui/ImGuiUtils.cs index 0c0de75e..3082896e 100644 --- a/Yafc.UI/ImGui/ImGuiUtils.cs +++ b/Yafc.UI/ImGui/ImGuiUtils.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using SDL2; +using Yafc.I18n; namespace Yafc.UI; @@ -263,18 +264,18 @@ public static ButtonEvent BuildRadioButton(this ImGui gui, string option, bool s return click; } - public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList options, int selected, out int newSelected, + public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList options, int selected, out int newSelected, SchemeColor textColor = SchemeColor.None, bool enabled = true) - => gui.BuildRadioGroup([.. options.Select(o => (o, (string?)null))], selected, out newSelected, textColor, enabled); + => gui.BuildRadioGroup([.. options.Select(o => (o, (LocalizableString0?)null))], selected, out newSelected, textColor, enabled); - public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList<(string option, string? tooltip)> options, int selected, + public static bool BuildRadioGroup(this ImGui gui, IReadOnlyList<(LocalizableString0 option, LocalizableString0? 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)) { + if (options[i].tooltip != null) { _ = evt.WithTooltip(gui, options[i].tooltip!); } if (evt) { @@ -434,9 +435,10 @@ public static bool BuildSlider(this ImGui gui, float value, out float newValue, return true; } - public static bool BuildSearchBox(this ImGui gui, SearchQuery searchQuery, out SearchQuery newQuery, string placeholder = "Search", SetKeyboardFocus setKeyboardFocus = SetKeyboardFocus.No) { + public static bool BuildSearchBox(this ImGui gui, SearchQuery searchQuery, out SearchQuery newQuery, string? placeholder = null, SetKeyboardFocus setKeyboardFocus = SetKeyboardFocus.No) { newQuery = searchQuery; + placeholder ??= LSs.SearchHint; if (gui.BuildTextInput(searchQuery.query, out string newText, placeholder, Icon.Search, setKeyboardFocus: setKeyboardFocus)) { newQuery = new SearchQuery(newText); return true; diff --git a/Yafc.UI/ImGui/ScrollArea.cs b/Yafc.UI/ImGui/ScrollArea.cs index 8fef09ae..c954317d 100644 --- a/Yafc.UI/ImGui/ScrollArea.cs +++ b/Yafc.UI/ImGui/ScrollArea.cs @@ -295,6 +295,7 @@ public class VirtualScrollList(float height, Vector2 elementSize, Virtual private int elementsPerRow; private IReadOnlyList _data = []; private readonly int maxRowsVisible = MathUtils.Ceil(height / elementSize.Y) + BufferRows + 1; + private readonly Vector2 elementSize = elementSize.X > 0 && elementSize.Y > 0 ? elementSize : throw new ArgumentException("Both element size dimensions must be positive", nameof(elementSize)); private float _spacing; public float spacing { diff --git a/Yafc.UI/Rendering/Font.cs b/Yafc.UI/Rendering/Font.cs index c7a1f9b5..5fcee3f8 100644 --- a/Yafc.UI/Rendering/Font.cs +++ b/Yafc.UI/Rendering/Font.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using SDL2; namespace Yafc.UI; @@ -23,11 +24,28 @@ public FontFile.FontSize GetFontSize(float pixelsPreUnit) { return lastFontSize; } + /// + /// Returns if the current font has glyphs for all characters in the supplied string, or if + /// any characters do not have a glyph. + /// + public bool CanDraw(string value) { + nint handle = lastFontSize!.handle; + foreach (char ch in value) { + if (SDL_ttf.TTF_GlyphIsProvided(handle, ch) == 0) { + return false; + } + } + return true; + } + public IntPtr GetHandle(float pixelsPreUnit) => GetFontSize(pixelsPreUnit).handle; public float GetLineSize(float pixelsPreUnit) => GetFontSize(pixelsPreUnit).lineSize / pixelsPreUnit; public void Dispose() => file.Dispose(); + + public static bool FilesExist(string baseFileName) + => File.Exists($"Data/{baseFileName}-Regular.ttf") && File.Exists($"Data/{baseFileName}-Light.ttf"); } public sealed class FontFile(string fileName) : IDisposable { diff --git a/Yafc.UI/Yafc.UI.csproj b/Yafc.UI/Yafc.UI.csproj index 2bd0fd38..e33fb37a 100644 --- a/Yafc.UI/Yafc.UI.csproj +++ b/Yafc.UI/Yafc.UI.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/Yafc/Data/locale/en/yafc.cfg b/Yafc/Data/locale/en/yafc.cfg new file mode 100644 index 00000000..8f90cd72 --- /dev/null +++ b/Yafc/Data/locale/en/yafc.cfg @@ -0,0 +1,4 @@ +[yafc] +; Program.cs +N-items=__1__ item__plural_for_parameter__1__{1=|rest=s}__ +N-items-quoted="__1__ item__plural_for_parameter__1__{1=|rest=s}__" diff --git a/Yafc/Program.cs b/Yafc/Program.cs index 109ff82c..18b03570 100644 --- a/Yafc/Program.cs +++ b/Yafc/Program.cs @@ -1,6 +1,8 @@ using System; using System.IO; +using Yafc.I18n; using Yafc.Model; +using Yafc.Parser; using Yafc.UI; namespace Yafc; @@ -11,6 +13,10 @@ public static class Program { private static void Main(string[] args) { YafcLib.RegisterDefaultAnalysis(); Ui.Start(); + + // This must happen before Preferences.Instance, where we load the prefs file and the requested translation. + FactorioDataSource.LoadYafcLocale("en"); + string? overrideFont = Preferences.Instance.overrideFont; FontFile? overriddenFontFile = null; @@ -23,9 +29,16 @@ private static void Main(string[] args) { Console.Error.WriteException(ex); } + string baseFileName = "Roboto"; + if (WelcomeScreen.languageMapping.TryGetValue(Preferences.Instance.language, out LanguageInfo? language)) { + if (Font.FilesExist(language.BaseFontName)) { + baseFileName = language.BaseFontName; + } + } + hasOverriddenFont = overriddenFontFile != null; - Font.header = new Font(overriddenFontFile ?? new FontFile("Data/Roboto-Light.ttf"), 2f); - var regular = overriddenFontFile ?? new FontFile("Data/Roboto-Regular.ttf"); + Font.header = new Font(overriddenFontFile ?? new FontFile($"Data/{baseFileName}-Light.ttf"), 2f); + var regular = overriddenFontFile ?? new FontFile($"Data/{baseFileName}-Regular.ttf"); Font.subheader = new Font(regular, 1.5f); Font.productionTableHeader = new Font(regular, 1.23f); Font.text = new Font(regular, 1f); @@ -33,11 +46,11 @@ private static void Main(string[] args) { ProjectDefinition? cliProject = CommandLineParser.ParseArgs(args); if (CommandLineParser.errorOccured || CommandLineParser.helpRequested) { - Console.WriteLine("YAFC CE v" + YafcLib.version.ToString(3)); + Console.WriteLine(LSs.YafcWithVersion.L(YafcLib.version.ToString(3))); Console.WriteLine(); if (CommandLineParser.errorOccured) { - Console.WriteLine($"Error: {CommandLineParser.lastError}"); + Console.WriteLine(LSs.CommandLineError.L(CommandLineParser.lastError)); Console.WriteLine(); Environment.ExitCode = 1; } diff --git a/Yafc/Utils/CommandLineParser.cs b/Yafc/Utils/CommandLineParser.cs index af9efd3b..85b89a83 100644 --- a/Yafc/Utils/CommandLineParser.cs +++ b/Yafc/Utils/CommandLineParser.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Linq; +using Yafc.I18n; namespace Yafc; @@ -30,7 +31,7 @@ public static class CommandLineParser { if (!args[0].StartsWith("--")) { projectDefinition.dataPath = args[0]; if (!Directory.Exists(projectDefinition.dataPath)) { - lastError = $"Data path '{projectDefinition.dataPath}' does not exist."; + lastError = LSs.CommandLineErrorPathDoesNotExist.L(LSs.FolderTypeData, projectDefinition.modsPath); return null; } } @@ -42,12 +43,12 @@ public static class CommandLineParser { projectDefinition.modsPath = args[++i]; if (!Directory.Exists(projectDefinition.modsPath)) { - lastError = $"Mods path '{projectDefinition.modsPath}' does not exist."; + lastError = LSs.CommandLineErrorPathDoesNotExist.L(LSs.FolderTypeMods, projectDefinition.modsPath); return null; } } else { - lastError = "Missing argument for --mods-path."; + lastError = LSs.MissingCommandLineArgument.L("--mods-path"); return null; } break; @@ -58,12 +59,12 @@ public static class CommandLineParser { string? directory = Path.GetDirectoryName(projectDefinition.path); if (!Directory.Exists(directory)) { - lastError = $"Project directory for '{projectDefinition.path}' does not exist."; + lastError = LSs.CommandLineErrorPathDoesNotExist.L(LSs.FolderTypeProject, projectDefinition.path); return null; } } else { - lastError = "Missing argument for --project-file."; + lastError = LSs.MissingCommandLineArgument.L("--project-file"); return null; } break; @@ -77,7 +78,7 @@ public static class CommandLineParser { break; default: - lastError = $"Unknown argument '{args[i]}'."; + lastError = LSs.CommandLineErrorUnknownArgument.L(args[i]); return null; } } @@ -85,52 +86,7 @@ public static class CommandLineParser { return projectDefinition; } - public static void PrintHelp() => Console.WriteLine(@"Usage: -Yafc [ [--mods-path ] [--project-file ] [--help] - -Description: - Yafc can be started without any arguments. However, if arguments are supplied, it is - mandatory that the first argument is the path to the data directory of Factorio. The - other arguments are optional in any case. - -Options: - - Path of the data directory (mandatory if other arguments are supplied) - - --mods-path - Path of the mods directory (optional) - - --project-file - Path of the project file (optional) - - --help - Display this help message and exit - -Examples: - 1. Starting Yafc without any arguments: - $ ./Yafc - This opens the welcome screen. - - 2. Starting Yafc with a project path: - $ ./Yafc path/to/my/project.yafc - Skips the welcome screen and loads the project. If the project has not been - opened before, then uses the start-settings of the most-recently-opened project. - - 3. Starting Yafc with the path to the data directory of Factorio: - $ ./Yafc Factorio/data - This opens a fresh project and loads the game data from the supplied directory. - Fails if the directory does not exist. - - 4. Starting Yafc with the paths to the data directory and a project file: - $ ./Yafc Factorio/data --project-file my-project.yafc - This opens the supplied project and loads the game data from the supplied data - directory. Fails if the directory and/or the project file do not exist. - - 5. Starting Yafc with the paths to the data & mods directories and a project file: - $ ./Yafc Factorio/data --mods-path Factorio/mods --project-file my-project.yafc - This opens the supplied project and loads the game data and mods from the supplied - data and mods directories. Fails if any of the directories and/or the project file - do not exist."); + public static void PrintHelp() => Console.WriteLine(LSs.ConsoleHelpMessage); /// /// Loads the project from the given path.
diff --git a/Yafc/Utils/Preferences.cs b/Yafc/Utils/Preferences.cs index 4ff511bb..3bccf718 100644 --- a/Yafc/Utils/Preferences.cs +++ b/Yafc/Utils/Preferences.cs @@ -4,6 +4,7 @@ using System.Runtime.InteropServices; using System.Text.Json; using Yafc.Model; +using Yafc.Parser; namespace Yafc; @@ -11,6 +12,7 @@ public class Preferences { public static readonly Preferences Instance; public static readonly string appDataFolder; private static readonly string fileName; + private string _language = "en"; static Preferences() { appDataFolder = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); @@ -41,7 +43,18 @@ public void Save() { } public ProjectDefinition[] recentProjects { get; set; } = []; public bool darkMode { get; set; } - public string language { get; set; } = "en"; + public string language { + get => _language; + set { + _language = value; + // An intentional but possibly undesirable choice: We never reload the English locale unless the user explicitly selects English. + // As a result, a user could select pt-BR, then pt-PT, and use Yafc strings first from Portuguese, then Brazilian Portuguese, and + // then from English. + // This configuration cannot be saved and does not propagate to the mods. + // TODO: Update i18n to support proper fallbacking, both in Yafc and Factorio strings. + FactorioDataSource.LoadYafcLocale(value); + } + } public string? overrideFont { get; set; } /// /// Whether or not the main screen should be created maximized. diff --git a/Yafc/Widgets/ImmediateWidgets.cs b/Yafc/Widgets/ImmediateWidgets.cs index 773163f2..fb4c64f0 100644 --- a/Yafc/Widgets/ImmediateWidgets.cs +++ b/Yafc/Widgets/ImmediateWidgets.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -191,7 +192,7 @@ public static Click BuildFactorioObjectButtonWithText(this ImGui gui, IFactorioO gui.BuildText(extraText, TextBlockDisplayStyle.Default(color)); } _ = gui.RemainingRow(); - gui.BuildText(obj == null ? "None" : obj.target.locName, TextBlockDisplayStyle.WrappedText with { Color = color }); + gui.BuildText(obj == null ? LSs.FactorioObjectNone : obj.target.locName, TextBlockDisplayStyle.WrappedText with { Color = color }); } return gui.BuildFactorioObjectButtonBackground(gui.lastRect, obj, tooltipOptions: tooltipOptions); @@ -236,7 +237,7 @@ public static void BuildInlineObjectListAndButton(this ImGui gui, ICollection } } - if (list.Count > options.MaxCount && gui.BuildButton("See full list") && gui.CloseDropdown()) { + if (list.Count > options.MaxCount && gui.BuildButton(LSs.SeeFullListButton) && gui.CloseDropdown()) { if (options.Multiple) { SelectMultiObjectPanel.Select(list, options.Header, selectItem, options.Ordering, options.Checkmark, options.YellowMark); } @@ -253,11 +254,11 @@ public static void BuildInlineObjectListAndButtonWithNone(this ImGui gui, ICo selectItem(selected); _ = gui.CloseDropdown(); } - if (gui.BuildRedButton("Clear") && gui.CloseDropdown()) { + if (gui.BuildRedButton(LSs.ClearButton) && gui.CloseDropdown()) { selectItem(null); } - if (list.Count > options.MaxCount && gui.BuildButton("See full list") && gui.CloseDropdown()) { + if (list.Count > options.MaxCount && gui.BuildButton(LSs.SeeFullListButton) && gui.CloseDropdown()) { SelectSingleObjectPanel.SelectWithNone(list, options.Header, selectItem, options.Ordering); } } @@ -293,13 +294,13 @@ public static void ShowPrecisionValueTooltip(ImGui gui, DisplayAmount amount, IF case UnitOfMeasure.PerSecond: case UnitOfMeasure.FluidPerSecond: case UnitOfMeasure.ItemPerSecond: - string perSecond = DataUtils.FormatAmountRaw(amount.Value, 1f, "/s", DataUtils.PreciseFormat); - string perMinute = DataUtils.FormatAmountRaw(amount.Value, 60f, "/m", DataUtils.PreciseFormat); - string perHour = DataUtils.FormatAmountRaw(amount.Value, 3600f, "/h", DataUtils.PreciseFormat); + string perSecond = DataUtils.FormatAmountRaw(amount.Value, 1f, LSs.PerSecondSuffix, DataUtils.PreciseFormat); + string perMinute = DataUtils.FormatAmountRaw(amount.Value, 60f, LSs.PerMinuteSuffix, DataUtils.PreciseFormat); + string perHour = DataUtils.FormatAmountRaw(amount.Value, 3600f, LSs.PerHourSuffix, DataUtils.PreciseFormat); text = perSecond + "\n" + perMinute + "\n" + perHour; if (goods.target is Item item) { - text += DataUtils.FormatAmount(MathF.Abs(item.stackSize / amount.Value), UnitOfMeasure.Second, "\n", " per stack"); + text += "\n" + LSs.SecondsPerStack.L(DataUtils.FormatAmount(MathF.Abs(item.stackSize / amount.Value), UnitOfMeasure.Second)); } break; @@ -323,14 +324,14 @@ public static void BuildObjectSelectDropDown(this ImGui gui, ICollection l /// If not , this will be called, and the dropdown will be closed, when the user selects a quality. /// In general, set this parameter when modifying an existing object, and leave it when setting a new object. /// Width of the popup. Make sure the header text fits! - public static void BuildObjectQualitySelectDropDown(this ImGui gui, ICollection list, Action> selectItem, ObjectSelectOptions options, + public static void BuildObjectQualitySelectDropDown(this ImGui gui, ICollection list, Action> selectItem, ObjectSelectOptions options, Quality quality, Action? selectQuality = null, float width = 20f) where T : FactorioObject => gui.ShowDropDown(gui => { if (gui.BuildQualityList(quality, out quality) && selectQuality != null && gui.CloseDropdown()) { selectQuality(quality); } - gui.BuildInlineObjectListAndButton(list, i => selectItem(new(i, quality)), options); + gui.BuildInlineObjectListAndButton(list, i => selectItem(i.With(quality)), options); }, width); /// Shows a dropdown containing the (partial) of elements, with an action for when an element is selected. @@ -343,14 +344,14 @@ public static void BuildObjectSelectDropDownWithNone(this ImGui gui, ICollect /// Also shows the available quality levels, and allows the user to select a quality. /// This will be called, and the dropdown will be closed, when the user selects a quality. /// Width of the popup. Make sure the header text fits! - public static void BuildObjectQualitySelectDropDownWithNone(this ImGui gui, ICollection list, Action?> selectItem, ObjectSelectOptions options, + public static void BuildObjectQualitySelectDropDownWithNone(this ImGui gui, ICollection list, Action?> selectItem, ObjectSelectOptions options, Quality quality, Action selectQuality, float width = 20f) where T : FactorioObject => gui.ShowDropDown(gui => { if (gui.BuildQualityList(quality, out quality) && gui.CloseDropdown()) { selectQuality(quality); } - gui.BuildInlineObjectListAndButtonWithNone(list, i => selectItem((i, quality)), options); + gui.BuildInlineObjectListAndButtonWithNone(list, i => selectItem(i.With(quality)), options); }, width); /// Draws a button displaying the icon belonging to a , or an empty box as a placeholder if no object is available. @@ -385,19 +386,21 @@ public static GoodsWithAmountEvent BuildFactorioObjectWithEditableAmount(this Im /// /// The to initially display selected, if any. /// The selected by the user. - /// The header text to draw, defaults to "Select quality" + /// The localizable string for the text to draw, defaults to /// if the user selected a quality. if they did not, or if the loaded mods do not provide multiple qualities. - public static bool BuildQualityList(this ImGui gui, Quality? quality, [NotNullWhen(true), NotNullIfNotNull(nameof(quality))] out Quality? newQuality, string header = "Select quality", bool drawCentered = false) { + public static bool BuildQualityList(this ImGui gui, Quality? quality, [NotNullWhen(true), NotNullIfNotNull(nameof(quality))] out Quality? newQuality, LocalizableString0? headerKey = null, bool drawCentered = false) { newQuality = quality; if (Quality.Normal.nextQuality == null) { return false; // Nothing to do; normal quality is the only one defined. } + headerKey ??= LSs.SelectQuality; + if (drawCentered) { - gui.BuildText(header, TextBlockDisplayStyle.Centered with { Font = Font.productionTableHeader }); + gui.BuildText(headerKey, TextBlockDisplayStyle.Centered with { Font = Font.productionTableHeader }); } else { - gui.BuildText(header, Font.productionTableHeader); + gui.BuildText(headerKey, Font.productionTableHeader); } using ImGui.OverlappingAllocations controller = gui.StartOverlappingAllocations(false); diff --git a/Yafc/Widgets/MainScreenTabBar.cs b/Yafc/Widgets/MainScreenTabBar.cs index 4e5559ae..380d7421 100644 --- a/Yafc/Widgets/MainScreenTabBar.cs +++ b/Yafc/Widgets/MainScreenTabBar.cs @@ -1,6 +1,7 @@ using System; using System.Numerics; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -108,23 +109,23 @@ private void BuildContents(ImGui gui) { private void PageRightClickDropdown(ImGui gui, ProjectPage page) { bool isSecondary = screen.secondaryPage == page; bool isActive = screen.activePage == page; - if (gui.BuildContextMenuButton("Edit properties")) { + if (gui.BuildContextMenuButton(LSs.EditPageProperties)) { _ = gui.CloseDropdown(); ProjectPageSettingsPanel.Show(page); } if (!isSecondary && !isActive) { - if (gui.BuildContextMenuButton("Open as secondary", "Ctrl+Click")) { + if (gui.BuildContextMenuButton(LSs.OpenSecondaryPage, LSs.ShortcutCtrlClick)) { _ = gui.CloseDropdown(); screen.SetSecondaryPage(page); } } else if (isSecondary) { - if (gui.BuildContextMenuButton("Close secondary", "Ctrl+Click")) { + if (gui.BuildContextMenuButton(LSs.CloseSecondaryPage, LSs.ShortcutCtrlClick)) { _ = gui.CloseDropdown(); screen.SetSecondaryPage(null); } } - if (gui.BuildContextMenuButton("Duplicate")) { + if (gui.BuildContextMenuButton(LSs.DuplicatePage)) { _ = gui.CloseDropdown(); if (ProjectPageSettingsPanel.ClonePage(page) is { } copy) { screen.project.RecordUndo().pages.Add(copy); diff --git a/Yafc/Widgets/ObjectTooltip.cs b/Yafc/Widgets/ObjectTooltip.cs index 1a4dbbbd..745b40be 100644 --- a/Yafc/Widgets/ObjectTooltip.cs +++ b/Yafc/Widgets/ObjectTooltip.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -39,7 +41,7 @@ private void BuildHeader(ImGui gui) { using (gui.EnterGroup(new Padding(1f, 0.5f), RectAllocator.LeftAlign, spacing: 0f)) { string name = target.text; if (tooltipOptions.ShowTypeInHeader && target is not Goods) { - name = name + " (" + target.target.type + ")"; + name = LSs.NameWithType.L(name, target.target.type); } gui.BuildText(name, new TextBlockDisplayStyle(Font.header, true)); @@ -101,7 +103,7 @@ private static void BuildIconRow(ImGui gui, IReadOnlyList EnergyDescriptions = new Dictionary + private static readonly Dictionary EnergyDescriptions = new() { - {EntityEnergyType.Electric, "Power usage: "}, - {EntityEnergyType.Heat, "Heat energy usage: "}, - {EntityEnergyType.Labor, "Labor energy usage: "}, - {EntityEnergyType.Void, "Free energy usage: "}, - {EntityEnergyType.FluidFuel, "Fluid fuel energy usage: "}, - {EntityEnergyType.FluidHeat, "Fluid heat energy usage: "}, - {EntityEnergyType.SolidFuel, "Solid fuel energy usage: "}, + {EntityEnergyType.Electric, LSs.EnergyElectricity}, + {EntityEnergyType.Heat, LSs.EnergyHeat}, + {EntityEnergyType.Labor, LSs.EnergyLabor}, + {EntityEnergyType.Void, LSs.EnergyFree}, + {EntityEnergyType.FluidFuel, LSs.EnergyFluidFuel}, + {EntityEnergyType.FluidHeat, LSs.EnergyFluidHeat}, + {EntityEnergyType.SolidFuel, LSs.EnergySolidFuel}, }; private void BuildEntity(Entity entity, Quality quality, ImGui gui) { if (entity.loot.Length > 0) { - BuildSubHeader(gui, "Loot"); + BuildSubHeader(gui, LSs.TooltipHeaderLoot); using (gui.EnterGroup(contentPadding)) { foreach (var product in entity.loot) { BuildItem(gui, product); @@ -228,42 +228,43 @@ private void BuildEntity(Entity entity, Quality quality, 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)) + ")", + gui.BuildText(entity.mapGenDensity <= 0 ? LSs.MapGenerationDensityUnknown + : LSs.MapGenerationDensity.L(DataUtils.FormatAmount(entity.mapGenDensity, UnitOfMeasure.None)), TextBlockDisplayStyle.WrappedText); } } if (entity is EntityCrafter crafter) { if (crafter.recipes.Length > 0) { - BuildSubHeader(gui, "Crafts"); + BuildSubHeader(gui, LSs.EntityCrafts); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, crafter.recipes, 2); if (crafter.CraftingSpeed(quality) != 1f) { - gui.BuildText(DataUtils.FormatAmount(crafter.CraftingSpeed(quality), UnitOfMeasure.Percent, "Crafting speed: ")); + gui.BuildText(LSs.EntityCraftingSpeed.L(DataUtils.FormatAmount(crafter.CraftingSpeed(quality), UnitOfMeasure.Percent))); } Effect baseEffect = crafter.effectReceiver.baseEffect; if (baseEffect.speed != 0f) { - gui.BuildText(DataUtils.FormatAmount(baseEffect.speed, UnitOfMeasure.Percent, "Crafting speed: ")); + gui.BuildText(LSs.EntityCraftingSpeed.L(DataUtils.FormatAmount(baseEffect.speed, UnitOfMeasure.Percent))); } if (baseEffect.productivity != 0f) { - gui.BuildText(DataUtils.FormatAmount(baseEffect.productivity, UnitOfMeasure.Percent, "Crafting productivity: ")); + gui.BuildText(LSs.EntityCraftingProductivity.L(DataUtils.FormatAmount(baseEffect.productivity, UnitOfMeasure.Percent))); } if (baseEffect.consumption != 0f) { - gui.BuildText(DataUtils.FormatAmount(baseEffect.consumption, UnitOfMeasure.Percent, "Energy consumption: ")); + gui.BuildText(LSs.EntityEnergyConsumption.L(DataUtils.FormatAmount(baseEffect.consumption, UnitOfMeasure.Percent))); } if (crafter.allowedEffects != AllowedEffects.None) { - gui.BuildText("Module slots: " + crafter.moduleSlots); + gui.BuildText(LSs.EntityModuleSlots.L(crafter.moduleSlots)); if (crafter.allowedEffects != AllowedEffects.All) { - gui.BuildText("Only allowed effects: " + crafter.allowedEffects, TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.AllowedModuleEffectsUntranslatedList.L(crafter.allowedEffects, BitOperations.PopCount((uint)crafter.allowedEffects)), TextBlockDisplayStyle.WrappedText); } } } } if (crafter.inputs != null) { - BuildSubHeader(gui, "Allowed inputs:"); + BuildSubHeader(gui, LSs.LabAllowedInputs.L(crafter.inputs)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, crafter.inputs, 2); } @@ -272,23 +273,23 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { float spoilTime = entity.GetSpoilTime(quality); // The spoiling rate setting does not apply to entities. if (spoilTime != 0f) { - BuildSubHeader(gui, "Perishable"); + BuildSubHeader(gui, LSs.Perishable); using (gui.EnterGroup(contentPadding)) { if (entity.spoilResult != null) { - gui.BuildText($"After {DataUtils.FormatTime(spoilTime)} of no production, spoils into"); - gui.BuildFactorioObjectButtonWithText(new ObjectWithQuality(entity.spoilResult, quality), iconDisplayStyle: IconDisplayStyle.Default with { AlwaysAccessible = true }); + gui.BuildText(LSs.TooltipEntitySpoilsAfterNoProduction.L(DataUtils.FormatTime(spoilTime))); + gui.BuildFactorioObjectButtonWithText(entity.spoilResult.With(quality), iconDisplayStyle: IconDisplayStyle.Default with { AlwaysAccessible = true }); } else { - gui.BuildText($"Expires after {DataUtils.FormatTime(spoilTime)} of no production"); + gui.BuildText(LSs.TooltipEntityExpiresAfterNoProduction.L(DataUtils.FormatTime(spoilTime))); } tooltipOptions.ExtraSpoilInformation?.Invoke(gui); } } if (entity.energy != null) { - string energyUsage = EnergyDescriptions[entity.energy.type] + DataUtils.FormatAmount(entity.Power(quality), UnitOfMeasure.Megawatt); + string energyUsage = EnergyDescriptions[entity.energy.type].L(DataUtils.FormatAmount(entity.Power(quality), UnitOfMeasure.Megawatt)); if (entity.energy.drain > 0f) { - energyUsage += " + " + DataUtils.FormatAmount(entity.energy.drain, UnitOfMeasure.Megawatt); + energyUsage = LSs.TooltipActivePlusDrainPower.L(energyUsage, DataUtils.FormatAmount(entity.energy.drain, UnitOfMeasure.Megawatt)); } BuildSubHeader(gui, energyUsage); @@ -302,15 +303,15 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { TextBlockDisplayStyle emissionStyle = TextBlockDisplayStyle.Default(SchemeColor.BackgroundText); if (amount < 0f) { emissionStyle = TextBlockDisplayStyle.Default(SchemeColor.Green); - gui.BuildText("This building absorbs " + name, emissionStyle); - gui.BuildText($"Absorption: {DataUtils.FormatAmount(-amount, UnitOfMeasure.None)} {name} per minute", emissionStyle); + gui.BuildText(LSs.EntityAbsorbsPollution.L(name), emissionStyle); + gui.BuildText(LSs.TooltipEntityAbsorbsPollution.L(DataUtils.FormatAmount(-amount, UnitOfMeasure.None), name), emissionStyle); } else { if (amount >= 20f) { emissionStyle = TextBlockDisplayStyle.Default(SchemeColor.Error); - gui.BuildText("This building contributes to global warning!", emissionStyle); + gui.BuildText(LSs.EntityHasHighPollution, emissionStyle); } - gui.BuildText($"Emission: {DataUtils.FormatAmount(amount, UnitOfMeasure.None)} {name} per minute", emissionStyle); + gui.BuildText(LSs.TooltipEntityEmitsPollution.L(DataUtils.FormatAmount(amount, UnitOfMeasure.None), name), emissionStyle); } } } @@ -321,9 +322,7 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { using (gui.EnterGroup(contentPadding)) using (gui.EnterRow(0)) { gui.AllocateRect(0, 1.5f); - gui.BuildText($"Requires {DataUtils.FormatAmount(entity.heatingPower, UnitOfMeasure.Megawatt)}"); - gui.BuildFactorioObjectIcon(Database.heat, IconDisplayStyle.Default with { Size = 1.5f }); - gui.BuildText("heat on cold planets."); + gui.BuildText(LSs.TooltipEntityRequiresHeat.L(DataUtils.FormatAmount(entity.heatingPower, UnitOfMeasure.Megawatt))); } } @@ -331,29 +330,26 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { switch (entity) { case EntityBelt belt: - miscText = "Belt throughput (Items): " + DataUtils.FormatAmount(belt.beltItemsPerSecond, UnitOfMeasure.PerSecond); + miscText = LSs.BeltThroughput.L(DataUtils.FormatAmount(belt.beltItemsPerSecond, UnitOfMeasure.PerSecond)); break; case EntityInserter inserter: - miscText = "Swing time: " + DataUtils.FormatAmount(inserter.inserterSwingTime, UnitOfMeasure.Second); + miscText = LSs.InserterSwingTime.L(DataUtils.FormatAmount(inserter.inserterSwingTime, UnitOfMeasure.Second)); break; case EntityBeacon beacon: - miscText = "Beacon efficiency: " + DataUtils.FormatAmount(beacon.BeaconEfficiency(quality), UnitOfMeasure.Percent); + miscText = LSs.BeaconEfficiency.L(DataUtils.FormatAmount(beacon.BeaconEfficiency(quality), UnitOfMeasure.Percent)); break; case EntityAccumulator accumulator: - miscText = "Accumulator charge: " + DataUtils.FormatAmount(accumulator.AccumulatorCapacity(quality), UnitOfMeasure.Megajoule); + miscText = LSs.AccumulatorCapacity.L(DataUtils.FormatAmount(accumulator.AccumulatorCapacity(quality), UnitOfMeasure.Megajoule)); break; case EntityAttractor attractor: if (attractor.baseCraftingSpeed > 0f) { - miscText = "Power production (average usable): " + DataUtils.FormatAmount(attractor.CraftingSpeed(quality), UnitOfMeasure.Megawatt); - miscText += $"\n Build in a {attractor.ConstructionGrid(quality)}-tile square grid"; - miscText += "\nProtection range: " + DataUtils.FormatAmount(attractor.Range(quality), UnitOfMeasure.None); - miscText += "\nCollection efficiency: " + DataUtils.FormatAmount(attractor.Efficiency(quality), UnitOfMeasure.Percent); + miscText = LSs.LightningAttractorExtraInfo.L(DataUtils.FormatAmount(attractor.CraftingSpeed(quality), UnitOfMeasure.Megawatt), attractor.ConstructionGrid(quality), DataUtils.FormatAmount(attractor.Range(quality), UnitOfMeasure.None), DataUtils.FormatAmount(attractor.Efficiency(quality), UnitOfMeasure.Percent)); } break; case EntityCrafter solarPanel: if (solarPanel.baseCraftingSpeed > 0f && entity.factorioType == "solar-panel") { - miscText = "Power production (average): " + DataUtils.FormatAmount(solarPanel.CraftingSpeed(quality), UnitOfMeasure.Megawatt); + miscText = LSs.SolarPanelAverageProduction.L(DataUtils.FormatAmount(solarPanel.CraftingSpeed(quality), UnitOfMeasure.Megawatt)); } break; } @@ -368,12 +364,12 @@ private void BuildEntity(Entity entity, Quality quality, ImGui gui) { private void BuildGoods(Goods goods, Quality quality, ImGui gui) { if (goods.showInExplorers) { using (gui.EnterGroup(contentPadding)) { - gui.BuildText("Middle mouse button to open Never Enough Items Explorer for this " + goods.type, TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.OpenNeieMiddleClickHint.L(goods.type), TextBlockDisplayStyle.WrappedText); } } if (goods.production.Length > 0) { - BuildSubHeader(gui, "Made with"); + BuildSubHeader(gui, LSs.TooltipHeaderProductionRecipes.L(goods.production.Length)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, goods.production, 2); if (tooltipOptions.HintLocations.HasFlag(HintLocations.OnProducingRecipes)) { @@ -384,14 +380,14 @@ private void BuildGoods(Goods goods, Quality quality, ImGui gui) { } if (goods.miscSources.Length > 0) { - BuildSubHeader(gui, "Sources"); + BuildSubHeader(gui, LSs.TooltipHeaderMiscellaneousSources.L(goods.miscSources.Length)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, goods.miscSources, 2); } } if (goods.usages.Length > 0) { - BuildSubHeader(gui, "Needed for"); + BuildSubHeader(gui, LSs.TooltipHeaderConsumptionRecipes.L(goods.usages.Length)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, goods.usages, 4); if (tooltipOptions.HintLocations.HasFlag(HintLocations.OnConsumingRecipes)) { @@ -402,10 +398,10 @@ private void BuildGoods(Goods goods, Quality quality, ImGui gui) { } if (goods is Item { spoilResult: FactorioObject spoiled } perishable) { - BuildSubHeader(gui, "Perishable"); + BuildSubHeader(gui, LSs.Perishable); using (gui.EnterGroup(contentPadding)) { float spoilTime = perishable.GetSpoilTime(quality) / Project.current.settings.spoilingRate; - gui.BuildText($"After {DataUtils.FormatTime(spoilTime)}, spoils into"); + gui.BuildText(LSs.TooltipItemSpoils.L(DataUtils.FormatTime(spoilTime))); gui.BuildFactorioObjectButtonWithText(spoiled, iconDisplayStyle: IconDisplayStyle.Default with { AlwaysAccessible = true }); tooltipOptions.ExtraSpoilInformation?.Invoke(gui); } @@ -413,10 +409,10 @@ private void BuildGoods(Goods goods, Quality quality, ImGui gui) { if (goods.fuelFor.Length > 0) { if (goods.fuelValue > 0f) { - BuildSubHeader(gui, "Fuel value " + DataUtils.FormatAmount(goods.fuelValue, UnitOfMeasure.Megajoule) + " used for:"); + BuildSubHeader(gui, LSs.FuelValueCanBeUsed.L(DataUtils.FormatAmount(goods.fuelValue, UnitOfMeasure.Megajoule))); } else { - BuildSubHeader(gui, "Can be used as fuel for:"); + BuildSubHeader(gui, LSs.FuelValueZeroCanBeUsed); } using (gui.EnterGroup(contentPadding)) { @@ -432,40 +428,40 @@ private void BuildGoods(Goods goods, Quality quality, ImGui gui) { } if (item.placeResult != null) { - BuildSubHeader(gui, "Place result"); + BuildSubHeader(gui, LSs.TooltipHeaderItemPlacementResult); using (gui.EnterGroup(contentPadding)) { BuildItem(gui, item.placeResult); } } if (item is Module { moduleSpecification: ModuleSpecification moduleSpecification }) { - BuildSubHeader(gui, "Module parameters"); + BuildSubHeader(gui, LSs.TooltipHeaderModuleProperties); using (gui.EnterGroup(contentPadding)) { if (moduleSpecification.baseProductivity != 0f) { - gui.BuildText(DataUtils.FormatAmount(moduleSpecification.Productivity(quality), UnitOfMeasure.Percent, "Productivity: ")); + gui.BuildText(LSs.ProductivityProperty.L(DataUtils.FormatAmount(moduleSpecification.Productivity(quality), UnitOfMeasure.Percent))); } if (moduleSpecification.baseSpeed != 0f) { - gui.BuildText(DataUtils.FormatAmount(moduleSpecification.Speed(quality), UnitOfMeasure.Percent, "Speed: ")); + gui.BuildText(LSs.SpeedProperty.L(DataUtils.FormatAmount(moduleSpecification.Speed(quality), UnitOfMeasure.Percent))); } if (moduleSpecification.baseConsumption != 0f) { - gui.BuildText(DataUtils.FormatAmount(moduleSpecification.Consumption(quality), UnitOfMeasure.Percent, "Consumption: ")); + gui.BuildText(LSs.ConsumptionProperty.L(DataUtils.FormatAmount(moduleSpecification.Consumption(quality), UnitOfMeasure.Percent))); } if (moduleSpecification.basePollution != 0f) { - gui.BuildText(DataUtils.FormatAmount(moduleSpecification.Pollution(quality), UnitOfMeasure.Percent, "Pollution: ")); + gui.BuildText(LSs.PollutionProperty.L(DataUtils.FormatAmount(moduleSpecification.Pollution(quality), UnitOfMeasure.Percent))); } if (moduleSpecification.baseQuality != 0f) { - gui.BuildText(DataUtils.FormatAmount(moduleSpecification.Quality(quality), UnitOfMeasure.Percent, "Quality: ")); + gui.BuildText(LSs.QualityProperty.L(DataUtils.FormatAmount(moduleSpecification.Quality(quality), UnitOfMeasure.Percent))); } } } using (gui.EnterGroup(contentPadding)) { - gui.BuildText("Stack size: " + item.stackSize); - gui.BuildText("Rocket capacity: " + DataUtils.FormatAmount(Database.rocketCapacity / item.weight, UnitOfMeasure.None)); + gui.BuildText(LSs.ItemStackSize.L(item.stackSize)); + gui.BuildText(LSs.ItemRocketCapacity.L(DataUtils.FormatAmount(item.rocketCapacity, UnitOfMeasure.None))); } } } @@ -490,43 +486,42 @@ private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { float waste = rec.RecipeWaste(); if (waste > 0.01f) { int wasteAmount = MathUtils.Round(waste * 100f); - string wasteText = ". (Wasting " + wasteAmount + "% of YAFC cost)"; TextBlockDisplayStyle style = TextBlockDisplayStyle.WrappedText with { Color = wasteAmount < 90 ? SchemeColor.BackgroundText : SchemeColor.Error }; if (recipe.products.Length == 1) { - gui.BuildText("YAFC analysis: There are better recipes to create " + recipe.products[0].goods.locName + wasteText, style); + gui.BuildText(LSs.AnalysisBetterRecipesToCreate.L(recipe.products[0].goods.locName, wasteAmount), style); } else if (recipe.products.Length > 0) { - gui.BuildText("YAFC analysis: There are better recipes to create each of the products" + wasteText, style); + gui.BuildText(LSs.AnalysisBetterRecipesToCreateAll.L(wasteAmount), style); } else { - gui.BuildText("YAFC analysis: This recipe wastes useful products. Don't do this recipe.", style); + gui.BuildText(LSs.AnalysisWastesUsefulProducts, style); } } } if (recipe.flags.HasFlags(RecipeFlags.UsesFluidTemperature)) { - gui.BuildText("Uses fluid temperature"); + gui.BuildText(LSs.RecipeUsesFluidTemperature); } if (recipe.flags.HasFlags(RecipeFlags.UsesMiningProductivity)) { - gui.BuildText("Uses mining productivity"); + gui.BuildText(LSs.RecipeUsesMiningProductivity); } if (recipe.flags.HasFlags(RecipeFlags.ScaleProductionWithPower)) { - gui.BuildText("Production scaled with power"); + gui.BuildText(LSs.RecipeProductionScalesWithPower); } } if (recipe is Recipe { products.Length: > 0 } && !(recipe.products.Length == 1 && recipe.products[0].IsSimple)) { - BuildSubHeader(gui, "Products"); + BuildSubHeader(gui, LSs.TooltipHeaderRecipeProducts.L(recipe.products.Length)); using (gui.EnterGroup(contentPadding)) { - string? extraText = recipe is Recipe { preserveProducts: true } ? ", preserved until removed from the machine" : null; + string? extraText = recipe is Recipe { preserveProducts: true } ? LSs.ProductSuffixPreserved : null; foreach (var product in recipe.products) { BuildItem(gui, product, extraText); } } } - BuildSubHeader(gui, "Made in"); + BuildSubHeader(gui, LSs.TooltipHeaderRecipeCrafters.L(recipe.crafters.Length)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, recipe.crafters, 2); } @@ -534,7 +529,7 @@ private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { List allowedModules = [.. Database.allModules.Where(recipe.CanAcceptModule)]; if (allowedModules.Count > 0) { - BuildSubHeader(gui, "Allowed modules"); + BuildSubHeader(gui, LSs.TooltipHeaderAllowedModules.L(allowedModules.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, allowedModules, 1); } @@ -558,7 +553,7 @@ private static void BuildRecipe(RecipeOrTechnology recipe, ImGui gui) { } if (recipe is Recipe lockedRecipe && !lockedRecipe.enabled) { - BuildSubHeader(gui, "Unlocked by"); + BuildSubHeader(gui, LSs.TooltipHeaderUnlockedByTechnologies.L(lockedRecipe.technologyUnlock.Length)); using (gui.EnterGroup(contentPadding)) { if (lockedRecipe.technologyUnlock.Length > 2) { BuildIconRow(gui, lockedRecipe.technologyUnlock, 1); @@ -596,19 +591,19 @@ private static void BuildTechnology(Technology technology, ImGui gui) { if (!technology.enabled) { using (gui.EnterGroup(contentPadding)) { - gui.BuildText("This technology is disabled and cannot be researched.", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.TechnologyIsDisabled, TextBlockDisplayStyle.WrappedText); } } if (technology.prerequisites.Length > 0) { - BuildSubHeader(gui, "Prerequisites"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyPrerequisites.L(technology.prerequisites.Length)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.prerequisites, 1); } } if (isResearchTriggerCraft) { - BuildSubHeader(gui, "Item crafting required"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyItemCrafting); using (gui.EnterGroup(contentPadding)) { using var grid = gui.EnterInlineGrid(3f); grid.Next(); @@ -616,46 +611,46 @@ private static void BuildTechnology(Technology technology, ImGui gui) { } } else if (isResearchTriggerCapture) { - BuildSubHeader(gui, technology.triggerEntities.Count == 1 ? "Capture this entity" : "Capture any entity"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyCapture.L(technology.triggerEntities.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.triggerEntities, 2); } } else if (isResearchTriggerMine) { - BuildSubHeader(gui, technology.triggerEntities.Count == 1 ? "Mine this entity" : "Mine any entity"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyMineEntity.L(technology.triggerEntities.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.triggerEntities, 2); } } else if (isResearchTriggerBuild) { - BuildSubHeader(gui, technology.triggerEntities.Count == 1 ? "Build this entity" : "Build any entity"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyBuildEntity.L(technology.triggerEntities.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.triggerEntities, 2); } } else if (isResearchTriggerPlatform) { List items = [.. Database.items.all.Where(i => i.factorioType == "space-platform-starter-pack")]; - BuildSubHeader(gui, items.Count == 1 ? "Launch this item" : "Launch any item"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyLaunchItem.L(items.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, items, 2); } } else if (isResearchTriggerLaunch) { - BuildSubHeader(gui, "Launch this item"); + BuildSubHeader(gui, LSs.TooltipHeaderTechnologyLaunchItem.L(1)); using (gui.EnterGroup(contentPadding)) { gui.BuildFactorioObjectButtonWithText(technology.triggerItem); } } if (technology.unlockRecipes.Count > 0) { - BuildSubHeader(gui, "Unlocks recipes"); + BuildSubHeader(gui, LSs.TooltipHeaderUnlocksRecipes.L(technology.unlockRecipes.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.unlockRecipes, 2); } } if (technology.unlockLocations.Count > 0) { - BuildSubHeader(gui, "Unlocks locations"); + BuildSubHeader(gui, LSs.TooltipHeaderUnlocksLocations.L(technology.unlockLocations.Count)); using (gui.EnterGroup(contentPadding)) { BuildIconRow(gui, technology.unlockLocations, 2); } @@ -663,7 +658,7 @@ private static void BuildTechnology(Technology technology, ImGui gui) { var packs = TechnologyScienceAnalysis.Instance.allSciencePacks[technology]; if (packs.Length > 0) { - BuildSubHeader(gui, "Total science required"); + BuildSubHeader(gui, LSs.TooltipHeaderTotalScienceRequired); using (gui.EnterGroup(contentPadding)) { using var grid = gui.EnterInlineGrid(3f); foreach (var pack in packs) { @@ -677,25 +672,25 @@ private static void BuildTechnology(Technology technology, ImGui gui) { private static void BuildQuality(Quality quality, ImGui gui) { using (gui.EnterGroup(contentPadding)) { if (quality.UpgradeChance > 0) { - gui.BuildText("Upgrade chance: " + DataUtils.FormatAmount(quality.UpgradeChance, UnitOfMeasure.Percent) + " (multiplied by module bonus)"); + gui.BuildText(LSs.TooltipQualityUpgradeChance.L(DataUtils.FormatAmount(quality.UpgradeChance, UnitOfMeasure.Percent))); } } - BuildSubHeader(gui, "Quality bonuses"); + BuildSubHeader(gui, LSs.TooltipHeaderQualityBonuses); using (gui.EnterGroup(contentPadding)) { if (quality == Quality.Normal) { - gui.BuildText("Normal quality provides no bonuses.", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.TooltipNoNormalBonuses, TextBlockDisplayStyle.WrappedText); return; } gui.allocator = RectAllocator.LeftAlign; (string left, string right)[] text = [ - ("Crafting speed:", '+' + DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent)), - ("Accumulator capacity:", '+' + DataUtils.FormatAmount(quality.AccumulatorCapacityBonus, UnitOfMeasure.Percent)), - ("Module effects:", '+' + DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent) + '*'), - ("Beacon transmission efficiency:", '+' + DataUtils.FormatAmount(quality.BeaconTransmissionBonus, UnitOfMeasure.None)), - ("Time before spoiling:", '+' + DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent)), - ("Lightning attractor range & efficiency:", '+' + DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent)), + (LSs.TooltipQualityCraftingSpeed, LSs.QualityBonusValue.L(DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent))), + (LSs.TooltipQualityAccumulatorCapacity, LSs.QualityBonusValue.L(DataUtils.FormatAmount(quality.AccumulatorCapacityBonus, UnitOfMeasure.Percent))), + (LSs.TooltipQualityModuleEffects, LSs.QualityBonusValueWithFootnote.L(DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent))), + (LSs.TooltipQualityBeaconTransmission, LSs.QualityBonusValue.L(DataUtils.FormatAmount(quality.BeaconTransmissionBonus, UnitOfMeasure.None))), + (LSs.TooltipQualityTimeBeforeSpoiling, LSs.QualityBonusValue.L(DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent))), + (LSs.TooltipQualityLightningAttractor, LSs.QualityBonusValue.L(DataUtils.FormatAmount(quality.StandardBonus, UnitOfMeasure.Percent))), ]; float rightWidth = text.Max(t => gui.GetTextDimensions(out _, t.right).X); @@ -706,7 +701,7 @@ private static void BuildQuality(Quality quality, ImGui gui) { Rect rect = new(gui.statePosition.Width - rightWidth, gui.lastRect.Y, rightWidth, gui.lastRect.Height); gui.DrawText(rect, right); } - gui.BuildText("* Only applied to beneficial module effects.", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.TooltipQualityModuleFootnote, TextBlockDisplayStyle.WrappedText); } } diff --git a/Yafc/Windows/AboutScreen.cs b/Yafc/Windows/AboutScreen.cs index 3cd223e1..b9e7311c 100644 --- a/Yafc/Windows/AboutScreen.cs +++ b/Yafc/Windows/AboutScreen.cs @@ -1,83 +1,81 @@ -using Yafc.UI; +using Yafc.I18n; +using Yafc.UI; namespace Yafc; public class AboutScreen : WindowUtility { public const string Github = "https://github.com/have-fun-was-taken/yafc-ce"; - public AboutScreen(Window parent) : base(ImGuiUtils.DefaultScreenPadding) => Create("About YAFC-CE", 50, parent); + public AboutScreen(Window parent) : base(ImGuiUtils.DefaultScreenPadding) => Create(LSs.AboutYafc, 50, parent); protected override void BuildContents(ImGui gui) { gui.allocator = RectAllocator.Center; - gui.BuildText("Yet Another Factorio Calculator", new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); - gui.BuildText("(Community Edition)", TextBlockDisplayStyle.Centered); - gui.BuildText("Copyright 2020-2021 ShadowTheAge", TextBlockDisplayStyle.Centered); - gui.BuildText("Copyright 2024 YAFC Community", TextBlockDisplayStyle.Centered); + gui.BuildText(LSs.FullName, new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); + gui.BuildText(LSs.AboutCommunityEdition, TextBlockDisplayStyle.Centered); + gui.BuildText(LSs.AboutCopyrightShadow, TextBlockDisplayStyle.Centered); + gui.BuildText(LSs.AboutCopyrightCommunity, TextBlockDisplayStyle.Centered); gui.allocator = RectAllocator.LeftAlign; gui.AllocateSpacing(1.5f); - 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); + gui.BuildText(LSs.AboutCopyleftGpl3, 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); + gui.BuildText(LSs.AboutWarrantyDisclaimer, TextBlockDisplayStyle.WrappedText); using (gui.EnterRow(0.3f)) { - gui.BuildText("Full license text:"); + gui.BuildText(LSs.AboutFullLicenseText); BuildLink(gui, "https://gnu.org/licenses/gpl-3.0.html"); } using (gui.EnterRow(0.3f)) { - gui.BuildText("Github YAFC-CE page and documentation:"); + gui.BuildText(LSs.AboutGithubPage); 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"); + gui.BuildText(LSs.AboutLibraries, Font.subheader); + BuildLink(gui, "https://dotnet.microsoft.com/", LSs.AboutDotNetCore); using (gui.EnterRow(0.3f)) { BuildLink(gui, "https://libsdl.org/index.php", "Simple DirectMedia Layer 2.0"); - gui.BuildText("and"); + gui.BuildText(LSs.AboutAnd); BuildLink(gui, "https://github.com/flibitijibibo/SDL2-CS", "SDL2-CS"); } using (gui.EnterRow(0.3f)) { - gui.BuildText("Libraries for SDL2:"); + gui.BuildText(LSs.AboutSdl2Libraries); BuildLink(gui, "http://libpng.org/pub/png/libpng.html", "libpng,"); BuildLink(gui, "http://libjpeg.sourceforge.net/", "libjpeg,"); BuildLink(gui, "https://freetype.org", "libfreetype"); - gui.BuildText("and"); + gui.BuildText(LSs.AboutAnd); BuildLink(gui, "https://zlib.net/", "zlib"); } using (gui.EnterRow(0.3f)) { gui.BuildText("Google"); BuildLink(gui, "https://developers.google.com/optimization", "OR-Tools,"); - BuildLink(gui, "https://fonts.google.com/specimen/Roboto", "Roboto font family"); - gui.BuildText("and"); - BuildLink(gui, "https://material.io/resources/icons", "Material Design Icon collection"); + BuildLink(gui, "https://fonts.google.com/specimen/Roboto", LSs.AboutRobotoFontFamily); + BuildLink(gui, "https://fonts.google.com/noto", LSs.AboutNotoSansFamily); + gui.BuildText(LSs.AboutAnd); + BuildLink(gui, "https://material.io/resources/icons", LSs.AboutMaterialDesignIcon); } using (gui.EnterRow(0.3f)) { BuildLink(gui, "https://lua.org/", "Lua 5.2"); - gui.BuildText("plus"); - BuildLink(gui, "https://github.com/pkulchenko/serpent", "Serpent library"); - gui.BuildText("and small bits from"); + gui.BuildText(LSs.AboutPlus); + BuildLink(gui, "https://github.com/pkulchenko/serpent", LSs.AboutSerpentLibrary); + gui.BuildText(LSs.AboutAndSmallBits); BuildLink(gui, "https://github.com/NLua", "NLua"); } using (gui.EnterRow(0.3f)) { - BuildLink(gui, "https://wiki.factorio.com/", "Documentation on Factorio Wiki"); - gui.BuildText("and"); - BuildLink(gui, "https://lua-api.factorio.com/latest/", "Factorio API reference"); + BuildLink(gui, "https://wiki.factorio.com/", LSs.AboutFactorioWiki); + gui.BuildText(LSs.AboutAnd); + BuildLink(gui, "https://lua-api.factorio.com/latest/", LSs.AboutFactorioLuaApi); } gui.AllocateSpacing(1.5f); gui.allocator = RectAllocator.Center; - gui.BuildText("Factorio name, content and materials are trademarks and copyrights of Wube Software"); + gui.BuildText(LSs.AboutFactorioTrademarkDisclaimer); BuildLink(gui, "https://factorio.com/"); } diff --git a/Yafc/Windows/DependencyExplorer.cs b/Yafc/Windows/DependencyExplorer.cs index 9b87f5b0..dc218a43 100644 --- a/Yafc/Windows/DependencyExplorer.cs +++ b/Yafc/Windows/DependencyExplorer.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -14,19 +15,19 @@ public class DependencyExplorer : PseudoScreen { private readonly List history = []; private FactorioObject current; - private static readonly Dictionary dependencyListTexts = new Dictionary() + private static readonly Dictionary dependencyListTexts = new() { - {DependencyNode.Flags.Fuel, ("Fuel", "There is no fuel to power this entity")}, - {DependencyNode.Flags.Ingredient, ("Ingredient", "There are no ingredients to this recipe")}, - {DependencyNode.Flags.IngredientVariant, ("Ingredient", "There are no ingredient variants for this recipe")}, - {DependencyNode.Flags.CraftingEntity, ("Crafter", "There are no crafters that can craft this item")}, - {DependencyNode.Flags.Source, ("Source", "This item have no sources")}, - {DependencyNode.Flags.TechnologyUnlock, ("Research", "This recipe is disabled and there are no technologies to unlock it")}, - {DependencyNode.Flags.TechnologyPrerequisites, ("Research", "There are no technology prerequisites")}, - {DependencyNode.Flags.ItemToPlace, ("Item", "This entity cannot be placed")}, - {DependencyNode.Flags.SourceEntity, ("Source", "This recipe requires another entity")}, - {DependencyNode.Flags.Disabled, ("", "This technology is disabled")}, - {DependencyNode.Flags.Location, ("Location", "There are no locations that spawn this entity")}, + {DependencyNode.Flags.Fuel, (LSs.DependencyFuel, LSs.DependencyFuelMissing)}, + {DependencyNode.Flags.Ingredient, (LSs.DependencyIngredient, LSs.DependencyIngredientMissing)}, + {DependencyNode.Flags.IngredientVariant, (LSs.DependencyIngredient, LSs.DependencyIngredientVariantsMissing)}, + {DependencyNode.Flags.CraftingEntity, (LSs.DependencyCrafter, LSs.DependencyCrafterMissing)}, + {DependencyNode.Flags.Source, (LSs.DependencySource, LSs.DependencySourcesMissing)}, + {DependencyNode.Flags.TechnologyUnlock, (LSs.DependencyTechnology, LSs.DependencyTechnologyMissing)}, + {DependencyNode.Flags.TechnologyPrerequisites, (LSs.DependencyTechnology, LSs.DependencyTechnologyNoPrerequisites)}, + {DependencyNode.Flags.ItemToPlace, (LSs.DependencyItem, LSs.DependencyItemMissing)}, + {DependencyNode.Flags.SourceEntity, (LSs.DependencySource, LSs.DependencyMapSourceMissing)}, + {DependencyNode.Flags.Disabled, (null, LSs.DependencyTechnologyDisabled)}, + {DependencyNode.Flags.Location, (LSs.DependencyLocation, LSs.DependencyLocationMissing)}, }; public DependencyExplorer(FactorioObject current) : base(60f) { @@ -41,7 +42,7 @@ private void DrawFactorioObject(ImGui gui, FactorioId id) { FactorioObject obj = Database.objects[id]; using (gui.EnterGroup(listPad, RectAllocator.LeftRow)) { gui.BuildFactorioObjectIcon(obj); - string text = obj.locName + " (" + obj.type + ")"; + string text = LSs.NameWithType.L(obj.locName, obj.type); gui.RemainingRow(0.5f).BuildText(text, TextBlockDisplayStyle.WrappedText with { Color = obj.IsAccessible() ? SchemeColor.BackgroundText : SchemeColor.BackgroundTextFaint }); } if (gui.BuildFactorioObjectButtonBackground(gui.lastRect, obj, tooltipOptions: new() { ShowTypeInHeader = true }) == Click.Left) { @@ -53,20 +54,27 @@ private void DrawDependencies(ImGui gui) { gui.spacing = 0f; Dependencies.dependencyList[current].Draw(gui, (gui, elements, flags) => { - if (!dependencyListTexts.TryGetValue(flags, out var dependencyType)) { - dependencyType = (flags.ToString(), "Missing " + flags); + string name; + string missingText; + if (dependencyListTexts.TryGetValue(flags, out var dependencyType)) { + name = dependencyType.name?.L(elements.Count) ?? ""; + missingText = dependencyType.missingText; + } + else { + name = flags.ToString(); + missingText = "Missing " + flags; } if (elements.Count > 0) { gui.AllocateSpacing(0.5f); if (elements.Count == 1) { - gui.BuildText("Require this " + dependencyType.name + ":"); + gui.BuildText(LSs.DependencyRequireSingle.L(name)); } else if (flags.HasFlags(DependencyNode.Flags.RequireEverything)) { - gui.BuildText("Require ALL of these " + dependencyType.name + "s:"); + gui.BuildText(LSs.DependencyRequireAll.L(name)); } else { - gui.BuildText("Require ANY of these " + dependencyType.name + "s:"); + gui.BuildText(LSs.DependencyRequireAny.L(name)); } gui.AllocateSpacing(0.5f); @@ -75,15 +83,14 @@ private void DrawDependencies(ImGui gui) { } } else { - string text = dependencyType.missingText; if (Database.rootAccessible.Contains(current)) { - text += ", but it is inherently accessible."; + missingText = LSs.DependencyAccessibleAnyway.L(missingText); } else { - text += ", and it is inaccessible."; + missingText = LSs.DependencyAndNotAccessible.L(missingText); } - gui.BuildText(text, TextBlockDisplayStyle.WrappedText); + gui.BuildText(missingText, TextBlockDisplayStyle.WrappedText); } }); } @@ -105,39 +112,39 @@ private void SetFlag(ProjectPerItemFlags flag, bool set) { public override void Build(ImGui gui) { gui.allocator = RectAllocator.Center; - BuildHeader(gui, "Dependency explorer"); + BuildHeader(gui, LSs.DependencyExplorer); using (gui.EnterRow()) { - gui.BuildText("Currently inspecting:", Font.subheader); + gui.BuildText(LSs.DependencyCurrentlyInspecting, Font.subheader); if (gui.BuildFactorioObjectButtonWithText(current) == Click.Left) { - SelectSingleObjectPanel.Select(Database.objects.explorable, "Select something", Change); + SelectSingleObjectPanel.Select(Database.objects.explorable, LSs.DependencySelectSomething, Change); } - gui.DrawText(gui.lastRect, "(Click to change)", RectAlignment.MiddleRight, color: TextBlockDisplayStyle.HintText.Color); + gui.DrawText(gui.lastRect, LSs.DependencyClickToChangeHint, RectAlignment.MiddleRight, color: TextBlockDisplayStyle.HintText.Color); } using (gui.EnterRow()) { var settings = Project.current.settings; if (current.IsAccessible()) { if (current.IsAutomatable()) { - gui.BuildText("Status: Automatable"); + gui.BuildText(LSs.DependencyAutomatable); } else { - gui.BuildText("Status: Accessible, Not automatable"); + gui.BuildText(LSs.DependencyAccessible); } if (settings.Flags(current).HasFlags(ProjectPerItemFlags.MarkedAccessible)) { - gui.BuildText("Manually marked as accessible."); - if (gui.BuildLink("Clear mark")) { + gui.BuildText(LSs.DependencyMarkedAccessible); + if (gui.BuildLink(LSs.DependencyClearMark)) { SetFlag(ProjectPerItemFlags.MarkedAccessible, false); NeverEnoughItemsPanel.Refresh(); } } else { - if (gui.BuildLink("Mark as inaccessible")) { + if (gui.BuildLink(LSs.DependencyMarkNotAccessible)) { SetFlag(ProjectPerItemFlags.MarkedInaccessible, true); NeverEnoughItemsPanel.Refresh(); } - if (gui.BuildLink("Mark as accessible without milestones")) { + if (gui.BuildLink(LSs.DependencyMarkAccessibleIgnoringMilestones)) { SetFlag(ProjectPerItemFlags.MarkedAccessible, true); NeverEnoughItemsPanel.Refresh(); } @@ -145,15 +152,15 @@ public override void Build(ImGui gui) { } else { if (settings.Flags(current).HasFlags(ProjectPerItemFlags.MarkedInaccessible)) { - gui.BuildText("Status: Marked as inaccessible"); - if (gui.BuildLink("Clear mark")) { + gui.BuildText(LSs.DependencyMarkedNotAccessible); + if (gui.BuildLink(LSs.DependencyClearMark)) { SetFlag(ProjectPerItemFlags.MarkedInaccessible, false); NeverEnoughItemsPanel.Refresh(); } } else { - gui.BuildText("Status: Not accessible. Wrong?"); - if (gui.BuildLink("Manually mark as accessible")) { + gui.BuildText(LSs.DependencyNotAccessible); + if (gui.BuildLink(LSs.DependencyMarkAccessible)) { SetFlag(ProjectPerItemFlags.MarkedAccessible, true); NeverEnoughItemsPanel.Refresh(); } @@ -163,10 +170,10 @@ public override void Build(ImGui gui) { gui.AllocateSpacing(2f); using var split = gui.EnterHorizontalSplit(2); split.Next(); - gui.BuildText("Dependencies:", Font.subheader); + gui.BuildText(LSs.DependencyHeaderDependencies.L(Dependencies.dependencyList[current].Count()), Font.subheader); dependencies.Build(gui); split.Next(); - gui.BuildText("Dependents:", Font.subheader); + gui.BuildText(LSs.DependencyHeaderDependents.L(Dependencies.reverseDependencies[current].Count), Font.subheader); dependents.Build(gui); } diff --git a/Yafc/Windows/ErrorListPanel.cs b/Yafc/Windows/ErrorListPanel.cs index dea80745..7ad7aa3f 100644 --- a/Yafc/Windows/ErrorListPanel.cs +++ b/Yafc/Windows/ErrorListPanel.cs @@ -1,4 +1,5 @@ -using Yafc.Model; +using Yafc.I18n; +using Yafc.Model; using Yafc.UI; namespace Yafc; @@ -23,13 +24,13 @@ private void BuildErrorList(ImGui gui) { 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"); + BuildHeader(gui, LSs.ErrorLoadingFailed); } else if (collector.severity >= ErrorSeverity.MinorDataLoss) { - BuildHeader(gui, "Loading completed with errors"); + BuildHeader(gui, LSs.ErrorButLoadingSucceeded); } else { - BuildHeader(gui, "Analysis warnings"); + BuildHeader(gui, LSs.AnalysisWarnings); } verticalList.Build(gui); diff --git a/Yafc/Windows/FilesystemScreen.cs b/Yafc/Windows/FilesystemScreen.cs index b2adcc1b..7909f74e 100644 --- a/Yafc/Windows/FilesystemScreen.cs +++ b/Yafc/Windows/FilesystemScreen.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using SDL2; +using Yafc.I18n; using Yafc.UI; namespace Yafc; @@ -135,7 +136,7 @@ public void UpdatePossibleResult() { EntryType.Directory => (Icon.Folder, Path.GetFileName(data.location)), EntryType.Drive => (Icon.FolderOpen, data.location), EntryType.ParentDirectory => (Icon.Upload, ".."), - EntryType.CreateDirectory => (Icon.NewFolder, "Create directory here"), + EntryType.CreateDirectory => (Icon.NewFolder, LSs.BrowserCreateDirectory), _ => (Icon.Settings, Path.GetFileName(data.location)), }; diff --git a/Yafc/Windows/ImageSharePanel.cs b/Yafc/Windows/ImageSharePanel.cs index 77b176cc..8911e866 100644 --- a/Yafc/Windows/ImageSharePanel.cs +++ b/Yafc/Windows/ImageSharePanel.cs @@ -1,6 +1,7 @@ using System.IO; using System.Runtime.InteropServices; using SDL2; +using Yafc.I18n; using Yafc.UI; namespace Yafc; @@ -24,19 +25,19 @@ public ImageSharePanel(MemoryDrawingSurface surface, string name) { } public override void Build(ImGui gui) { - BuildHeader(gui, "Image generated"); + BuildHeader(gui, LSs.SharingImageGenerated); gui.BuildText(header, TextBlockDisplayStyle.WrappedText); - if (gui.BuildButton("Save as PNG")) { + if (gui.BuildButton(LSs.SaveAsPng)) { SaveAsPng(); } - if (gui.BuildButton("Save to temp folder and open")) { + if (gui.BuildButton(LSs.SaveAndOpen)) { surface.SavePng(TempImageFile); 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)) { + && gui.BuildButton(copied ? LSs.CopiedToClipboard : LSs.CopyToClipboardWithShortcut.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_C)), active: !copied)) { WindowsClipboard.CopySurfaceToClipboard(surface); copied = true; @@ -53,7 +54,7 @@ public override bool KeyDown(SDL.SDL_Keysym key) { } private async void SaveAsPng() { - string? path = await new FilesystemScreen(header, "Save as PNG", "Save", null, FilesystemScreen.Mode.SelectOrCreateFile, name + ".png", MainScreen.Instance, null, "png"); + string? path = await new FilesystemScreen(header, LSs.SaveAsPng, LSs.Save, null, FilesystemScreen.Mode.SelectOrCreateFile, name + ".png", MainScreen.Instance, null, "png"); if (path != null) { surface?.SavePng(path); } diff --git a/Yafc/Windows/MainScreen.PageListSearch.cs b/Yafc/Windows/MainScreen.PageListSearch.cs index 37dbb2e8..c4ba47eb 100644 --- a/Yafc/Windows/MainScreen.PageListSearch.cs +++ b/Yafc/Windows/MainScreen.PageListSearch.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -45,16 +46,16 @@ public void Build(ImGui gui, Action updatePageList) { } gui.SetTextInputFocus(gui.lastContentRect, query.query); - gui.BuildText("Search in:"); + gui.BuildText(LSs.SearchAllHeader); using (gui.EnterRow()) { - buildCheckbox(gui, "Page name", ref checkboxValues[(int)PageSearchOption.PageName]); - buildCheckbox(gui, "Desired products", ref checkboxValues[(int)PageSearchOption.DesiredProducts]); - buildCheckbox(gui, "Recipes", ref checkboxValues[(int)PageSearchOption.Recipes]); + buildCheckbox(gui, LSs.SearchAllLocationPageName, ref checkboxValues[(int)PageSearchOption.PageName]); + buildCheckbox(gui, LSs.SearchAllLocationOutputs, ref checkboxValues[(int)PageSearchOption.DesiredProducts]); + buildCheckbox(gui, LSs.SearchAllLocationRecipes, ref checkboxValues[(int)PageSearchOption.Recipes]); } using (gui.EnterRow()) { - buildCheckbox(gui, "Ingredients", ref checkboxValues[(int)PageSearchOption.Ingredients]); - buildCheckbox(gui, "Extra products", ref checkboxValues[(int)PageSearchOption.ExtraProducts]); - if (gui.BuildCheckBox("All", checkboxValues.All(x => x), out bool checkAll)) { + buildCheckbox(gui, LSs.SearchAllLocationInputs, ref checkboxValues[(int)PageSearchOption.Ingredients]); + buildCheckbox(gui, LSs.SearchAllLocationExtraOutputs, ref checkboxValues[(int)PageSearchOption.ExtraProducts]); + if (gui.BuildCheckBox(LSs.SearchAllLocationAll, checkboxValues.All(x => x), out bool checkAll)) { if (checkAll) { // Save the previous state, so we can restore it if necessary. Array.Copy(checkboxValues, previousCheckboxValues, (int)PageSearchOption.MustBeLastValue); @@ -68,9 +69,9 @@ public void Build(ImGui gui, Action updatePageList) { } } using (gui.EnterRow()) { - buildRadioButton(gui, "Localized names", SearchNameMode.Localized); - buildRadioButton(gui, "Internal names", SearchNameMode.Internal); - buildRadioButton(gui, "Both", SearchNameMode.Both); + buildRadioButton(gui, LSs.SearchAllLocalizedStrings, SearchNameMode.Localized); + buildRadioButton(gui, LSs.SearchAllInternalStrings, SearchNameMode.Internal); + buildRadioButton(gui, LSs.SearchAllBothStrings, SearchNameMode.Both); } } diff --git a/Yafc/Windows/MainScreen.cs b/Yafc/Windows/MainScreen.cs index d1546274..8ce82fc3 100644 --- a/Yafc/Windows/MainScreen.cs +++ b/Yafc/Windows/MainScreen.cs @@ -3,13 +3,13 @@ using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using System.Net.Http; using System.Numerics; using System.Text.Json; using System.Threading.Tasks; using SDL2; using Serilog; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -55,9 +55,9 @@ public MainScreen(int display, Project project) : base(default, Preferences.Inst searchGui = new ImGui(BuildSearch, new Padding(1f)) { boxShadow = RectangleBorder.Thin, boxColor = SchemeColor.Background }; Instance = this; tabBar = new MainScreenTabBar(this); - allPages = new VirtualScrollList(30, new Vector2(0f, 2f), BuildPage, collapsible: true); + allPages = new VirtualScrollList(30, new Vector2(float.PositiveInfinity, 2f), BuildPage, collapsible: true); - Create("Yet Another Factorio Calculator CE v" + YafcLib.version.ToString(3), display, Preferences.Instance.initialMainScreenWidth, + Create(LSs.FullNameWithVersion.L(YafcLib.version.ToString(3)), display, Preferences.Instance.initialMainScreenWidth, Preferences.Instance.initialMainScreenHeight, Preferences.Instance.maximizeMainScreen); SetProject(project); @@ -249,15 +249,13 @@ private void BuildTabBar(ImGui gui) { gui.ShowDropDown(gui.lastRect, SettingsDropdown, new Padding(0f, 0f, 0f, 0.5f)); } - if (gui.BuildButton(Icon.Plus).WithTooltip(gui, "Create production sheet (Ctrl+" + - ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_T) + ")")) { + if (gui.BuildButton(Icon.Plus).WithTooltip(gui, LSs.CreateProductionSheet.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_T)))) { ProductionTableView.CreateProductionSheet(); } gui.allocator = RectAllocator.RightRow; - if (gui.BuildButton(Icon.DropDown, SchemeColor.None, SchemeColor.Grey).WithTooltip(gui, "List and search all pages (Ctrl+Shift+" + - ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_F) + ")") || showSearchAll) { + if (gui.BuildButton(Icon.DropDown, SchemeColor.None, SchemeColor.Grey).WithTooltip(gui, LSs.ListAndSearchAll.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_F))) || showSearchAll) { showSearchAll = false; updatePageList(); ShowDropDown(gui, gui.lastRect, missingPagesDropdown, new Padding(0f, 0f, 0f, 0.5f), 30f); @@ -331,7 +329,7 @@ public static void BuildSubHeader(ImGui gui, string text) { } } - private static void ShowNeie() => SelectSingleObjectPanel.Select(Database.goods.explorable, "Open NEIE", NeverEnoughItemsPanel.Show); + private static void ShowNeie() => SelectSingleObjectPanel.Select(Database.goods.explorable, LSs.MenuOpenNeie, NeverEnoughItemsPanel.Show); private void SetSearch(SearchQuery searchQuery) { pageSearch = searchQuery; @@ -348,7 +346,7 @@ private void ShowSearch() { } private void BuildSearch(ImGui gui) { - gui.BuildText("Find on page:"); + gui.BuildText(LSs.SearchHeader); gui.AllocateSpacing(); gui.allocator = RectAllocator.RightRow; if (gui.BuildButton(Icon.Close)) { @@ -368,73 +366,73 @@ private void BuildSearch(ImGui gui) { private void SettingsDropdown(ImGui gui) { gui.boxColor = SchemeColor.Background; - if (gui.BuildContextMenuButton("Undo", "Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_Z)) && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.Undo, LSs.ShortcutCtrlX.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_Z))) && gui.CloseDropdown()) { project.undo.PerformUndo(); } - if (gui.BuildContextMenuButton("Save", "Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_S)) && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.Save, LSs.ShortcutCtrlX.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_S))) && gui.CloseDropdown()) { SaveProject().CaptureException(); } - if (gui.BuildContextMenuButton("Save As") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.SaveAs) && gui.CloseDropdown()) { SaveProjectAs().CaptureException(); } - if (gui.BuildContextMenuButton("Find on page", "Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_F)) && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.FindOnPage, LSs.ShortcutCtrlX.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_F))) && gui.CloseDropdown()) { ShowSearch(); } - if (gui.BuildContextMenuButton("Load another project (Same mods)") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.LoadWithSameMods) && gui.CloseDropdown()) { LoadProjectLight(); } - if (gui.BuildContextMenuButton("Return to starting screen") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.ReturnToWelcomeScreen) && gui.CloseDropdown()) { LoadProjectHeavy(); } - BuildSubHeader(gui, "Tools"); - if (gui.BuildContextMenuButton("Milestones") && gui.CloseDropdown()) { + BuildSubHeader(gui, LSs.MenuHeaderTools); + if (gui.BuildContextMenuButton(LSs.Milestones) && gui.CloseDropdown()) { _ = ShowPseudoScreen(new MilestonesPanel()); } - if (gui.BuildContextMenuButton("Preferences") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.Preferences) && gui.CloseDropdown()) { PreferencesScreen.ShowPreviousState(); } - if (gui.BuildContextMenuButton("Summary") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.MenuSummary) && gui.CloseDropdown()) { ShowSummaryTab(); } - if (gui.BuildContextMenuButton("Summary (Legacy)") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.MenuLegacySummary) && gui.CloseDropdown()) { ProjectPageSettingsPanel.Show(null, (name, icon) => Instance.AddProjectPage(name, icon, typeof(ProductionSummary), true, true)); } - if (gui.BuildContextMenuButton("Never Enough Items Explorer", "Ctrl+" + ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_N)) && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.Neie, LSs.ShortcutCtrlX.L(ImGuiUtils.ScanToString(SDL.SDL_Scancode.SDL_SCANCODE_N))) && gui.CloseDropdown()) { ShowNeie(); } - if (gui.BuildContextMenuButton("Dependency Explorer") && gui.CloseDropdown()) { - SelectSingleObjectPanel.Select(Database.objects.explorable, "Open Dependency Explorer", DependencyExplorer.Show); + if (gui.BuildContextMenuButton(LSs.DependencyExplorer) && gui.CloseDropdown()) { + SelectSingleObjectPanel.Select(Database.objects.explorable, LSs.DependencyExplorer, DependencyExplorer.Show); } - if (gui.BuildContextMenuButton("Import page from clipboard", disabled: !ImGuiUtils.HasClipboardText()) && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.MenuImportFromClipboard, disabled: !ImGuiUtils.HasClipboardText()) && gui.CloseDropdown()) { ProjectPageSettingsPanel.LoadProjectPageFromClipboard(); } - BuildSubHeader(gui, "Extra"); + BuildSubHeader(gui, LSs.MenuHeaderExtra); - if (gui.BuildContextMenuButton("Run Factorio")) { + if (gui.BuildContextMenuButton(LSs.MenuRunFactorio)) { string factorioPath = DataUtils.dataPath + "/../bin/x64/factorio"; string? args = string.IsNullOrEmpty(DataUtils.modsPath) ? null : "--mod-directory \"" + DataUtils.modsPath + "\""; _ = Process.Start(new ProcessStartInfo(factorioPath, args!) { UseShellExecute = true }); // null-forgiving: ProcessStartInfo permits null args. _ = gui.CloseDropdown(); } - if (gui.BuildContextMenuButton("Check for updates") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.MenuCheckForUpdates) && gui.CloseDropdown()) { DoCheckForUpdates(); } - if (gui.BuildContextMenuButton("About YAFC") && gui.CloseDropdown()) { + if (gui.BuildContextMenuButton(LSs.MenuAbout) && gui.CloseDropdown()) { _ = new AboutScreen(this); } } @@ -477,13 +475,16 @@ public void ForceClose() { } private async Task ConfirmUnsavedChanges() { - string unsavedCount = "You have " + project.unsavedChangesCount + " unsaved changes"; - if (!string.IsNullOrEmpty(project.attachedFileName)) { - unsavedCount += " to " + project.attachedFileName; + string unsavedCount; + if (string.IsNullOrEmpty(project.attachedFileName)) { + unsavedCount = LSs.AlertUnsavedChanges.L(project.unsavedChangesCount); + } + else { + unsavedCount = LSs.AlertUnsavedChangesInFile.L(project.unsavedChangesCount, project.attachedFileName); } saveConfirmationActive = true; - var (hasChoice, choice) = await MessageBox.Show("Save unsaved changes?", unsavedCount, "Save", "Don't save"); + var (hasChoice, choice) = await MessageBox.Show(LSs.QuerySaveChanges, unsavedCount, LSs.Save, LSs.DontSave); saveConfirmationActive = false; if (!hasChoice) { return false; @@ -511,21 +512,21 @@ private static async void DoCheckForUpdates() { var release = JsonSerializer.Deserialize(result)!; 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"); + var (_, answer) = await MessageBox.Show(LSs.NewVersionAvailable, LSs.NewVersionNumber.L(release.tag_name), LSs.VisitReleasePage, LSs.Close); if (answer) { Ui.VisitLink(release.html_url); } return; } - MessageBox.Show("No newer version", "You are running the latest version!", "Ok"); + MessageBox.Show(LSs.NoNewerVersion, LSs.RunningLatestVersion, LSs.Ok); } catch (Exception) { MessageBox.Show((hasAnswer, answer) => { if (answer) { Ui.VisitLink(AboutScreen.Github + "/releases"); } - }, "Network error", "There were an error while checking versions.", "Open releases url", "Close"); + }, LSs.NetworkError, LSs.ErrorWhileCheckingForNewVersion, LSs.VisitReleasePage, LSs.Close); } } @@ -563,7 +564,7 @@ public void ShowSummaryTab() { if (summaryPage == null) { summaryPage = new ProjectPage(project, typeof(Summary), SummaryGuid) { - name = "Summary", + name = LSs.MenuSummary, }; project.pages.Add(summaryPage); } @@ -637,9 +638,9 @@ public bool KeyDown(SDL.SDL_Keysym key) { } private async Task SaveProjectAs() { - string? projectPath = await new FilesystemScreen("Save project", "Save project as", "Save", + string? projectPath = await new FilesystemScreen(LSs.SaveProjectWindowTitle, LSs.SaveProjectWindowHeader, LSs.Save, string.IsNullOrEmpty(project.attachedFileName) ? null : Path.GetDirectoryName(project.attachedFileName), - FilesystemScreen.Mode.SelectOrCreateFile, "project", this, null, "yafc"); + FilesystemScreen.Mode.SelectOrCreateFile, LSs.DefaultFileName, this, null, "yafc"); if (projectPath != null) { project.Save(projectPath); Preferences.Instance.AddProject(DataUtils.dataPath, DataUtils.modsPath, projectPath, DataUtils.netProduction); @@ -664,8 +665,8 @@ private async void LoadProjectLight() { } 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"); + string? path = await new FilesystemScreen(LSs.LoadProjectWindowTitle, LSs.LoadProjectWindowHeader, LSs.Select, projectDirectory, + FilesystemScreen.Mode.SelectOrCreateFile, LSs.DefaultFileName, this, null, "yafc"); if (path == null) { return; @@ -678,7 +679,7 @@ private async void LoadProjectLight() { SetProject(project); } catch (Exception ex) { - errors.Exception(ex, "Critical loading exception", ErrorSeverity.Important); + errors.Exception(ex, LSs.ErrorCriticalLoadingException, ErrorSeverity.Important); } if (errors.severity != ErrorSeverity.None) { ErrorListPanel.Show(errors); @@ -749,7 +750,7 @@ public void ShowTooltip(ImGui gui, ProjectPage? page, bool isMiddleEdit, Rect re ShowTooltip(gui, rect, x => { pageView.BuildPageTooltip(x, page.content); if (isMiddleEdit) { - x.BuildText("Middle mouse button to edit", TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); + x.BuildText(LSs.SearchAllMiddleMouseToEditHint, TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); } }); } diff --git a/Yafc/Windows/MilestonesEditor.cs b/Yafc/Windows/MilestonesEditor.cs index 4485cca6..80eddcf6 100644 --- a/Yafc/Windows/MilestonesEditor.cs +++ b/Yafc/Windows/MilestonesEditor.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -44,15 +45,13 @@ private void MilestoneDrawer(ImGui gui, FactorioObject element, int index) { } public override void Build(ImGui gui) { - BuildHeader(gui, "Milestone editor"); + BuildHeader(gui, LSs.MilestoneEditor); milestoneList.Build(gui); - 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 }); + gui.BuildText(LSs.MilestoneDescription, TextBlockDisplayStyle.WrappedText with { Color = SchemeColor.BackgroundTextFaint }); using (gui.EnterRow()) { - if (gui.BuildButton("Auto sort milestones", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.MilestoneAutoSort, SchemeColor.Grey)) { ErrorCollector collector = new ErrorCollector(); Milestones.Instance.ComputeWithParameters(Project.current, collector, [.. Project.current.settings.milestones], true); @@ -62,8 +61,8 @@ public override void Build(ImGui gui) { milestoneList.RebuildContents(); } - if (gui.BuildButton("Add milestone")) { - SelectMultiObjectPanel.Select(Database.objects.explorable.Except(Project.current.settings.milestones), "Add new milestone", AddMilestone); + if (gui.BuildButton(LSs.MilestoneAdd)) { + SelectMultiObjectPanel.Select(Database.objects.explorable.Except(Project.current.settings.milestones), LSs.MilestoneAddNew, AddMilestone); } } } @@ -72,7 +71,7 @@ private void AddMilestone(FactorioObject obj) { var settings = Project.current.settings; if (settings.milestones.Contains(obj)) { - MessageBox.Show("Cannot add milestone", "Milestone already exists", "Ok"); + MessageBox.Show(LSs.MilestoneCannotAdd, LSs.MilestoneCannotAddAlreadyExists, LSs.Ok); return; } diff --git a/Yafc/Windows/MilestonesPanel.cs b/Yafc/Windows/MilestonesPanel.cs index d5fd95e9..faf68bdd 100644 --- a/Yafc/Windows/MilestonesPanel.cs +++ b/Yafc/Windows/MilestonesPanel.cs @@ -1,4 +1,5 @@ using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -39,26 +40,23 @@ public class MilestonesPanel : PseudoScreen { public override void Build(ImGui gui) { instance = milestonesWidget; gui.spacing = 1f; - BuildHeader(gui, "Milestones"); - gui.BuildText("Please select objects that you already have access to:"); + BuildHeader(gui, LSs.Milestones); + gui.BuildText(LSs.MilestonesHeader); gui.AllocateSpacing(2f); milestonesWidget.Build(gui); gui.AllocateSpacing(2f); - gui.BuildText("For your convenience, YAFC will show objects you DON'T have access to based on this selection", TextBlockDisplayStyle.WrappedText); - gui.BuildText("These are called 'Milestones'. By default all science packs and locations are added as milestones, but this does not have to be this way! " + - "You can define your own milestones: Any item, recipe, entity or technology may be added as a milestone. For example you can add advanced " + - "electronic circuits as a milestone, and YAFC will display everything that is locked behind those circuits", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.MilestonesDescription, TextBlockDisplayStyle.WrappedText); using (gui.EnterRow()) { - if (gui.BuildButton("Edit milestones", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.MilestonesEdit, SchemeColor.Grey)) { MilestonesEditor.Show(); } - if (gui.BuildButton("Edit tech progression settings")) { + if (gui.BuildButton(LSs.MilestonesEditSettings)) { Close(); PreferencesScreen.ShowProgression(); } - if (gui.RemainingRow().BuildButton("Done")) { + if (gui.RemainingRow().BuildButton(LSs.Done)) { Close(); } } diff --git a/Yafc/Windows/NeverEnoughItemsPanel.cs b/Yafc/Windows/NeverEnoughItemsPanel.cs index e758601b..2be42d99 100644 --- a/Yafc/Windows/NeverEnoughItemsPanel.cs +++ b/Yafc/Windows/NeverEnoughItemsPanel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -121,7 +122,7 @@ private void DrawIngredients(ImGui gui, Recipe recipe) { foreach (var ingredient in recipe.ingredients) { if (gui.BuildFactorioObjectWithAmount(ingredient.goods, ingredient.amount, ButtonDisplayStyle.NeieSmall) == Click.Left) { if (ingredient.variants != null) { - gui.ShowDropDown(imGui => imGui.BuildInlineObjectListAndButton(ingredient.variants, SetItem, new("Accepted fluid variants"))); + gui.ShowDropDown(imGui => imGui.BuildInlineObjectListAndButton(ingredient.variants, SetItem, new(LSs.NeieAcceptedVariants))); } else { changing = ingredient.goods; @@ -186,10 +187,10 @@ 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.BuildText(LSs.NeieBuildingHoursSuffix.L(DataUtils.FormatAmount(bh, UnitOfMeasure.None)), 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"); + .WithTooltip(gui, LSs.NeieBuildingHoursDescription); } } gui.AllocateSpacing(); @@ -255,7 +256,7 @@ private void DrawRecipeEntry(ImGui gui, RecipeEntry entry, bool production) { private void DrawEntryFooter(ImGui gui, bool production) { if (!production && current.fuelFor.Length > 0) { using (gui.EnterGroup(new Padding(0.5f), RectAllocator.LeftAlign)) { - gui.BuildText(current.fuelValue > 0f ? "Fuel value " + DataUtils.FormatAmount(current.fuelValue, UnitOfMeasure.Megajoule) + " can be used for:" : "Can be used to fuel:"); + gui.BuildText(current.fuelValue > 0f ? LSs.FuelValueCanBeUsed.L(DataUtils.FormatAmount(current.fuelValue, UnitOfMeasure.Megajoule)) : LSs.FuelValueZeroCanBeUsed); using var grid = gui.EnterInlineGrid(3f); foreach (var fuelUsage in current.fuelFor) { grid.Next(); @@ -285,10 +286,10 @@ private void DrawEntryList(ImGui gui, ReadOnlySpan entries, bool pr if (status < showRecipesRange) { DrawEntryFooter(gui, production); footerDrawn = true; - gui.BuildText(entry.entryStatus == EntryStatus.Special ? "Show special recipes (barreling / voiding)" : - entry.entryStatus == EntryStatus.NotAccessibleWithCurrentMilestones ? "There are more recipes, but they are locked based on current milestones" : - "There are more recipes but they are inaccessible", TextBlockDisplayStyle.WrappedText); - if (gui.BuildButton("Show more recipes")) { + gui.BuildText(entry.entryStatus == EntryStatus.Special ? LSs.NeieShowSpecialRecipes : + entry.entryStatus == EntryStatus.NotAccessibleWithCurrentMilestones ? LSs.NeieShowLockedRecipes : + LSs.NeieShowInaccessibleRecipes, TextBlockDisplayStyle.WrappedText); + if (gui.BuildButton(LSs.NeieShowMoreRecipes)) { ChangeShowStatus(status); } @@ -298,8 +299,10 @@ private void DrawEntryList(ImGui gui, ReadOnlySpan entries, bool pr if (status < prevEntryStatus) { prevEntryStatus = status; using (gui.EnterRow()) { - gui.BuildText(status == EntryStatus.Special ? "Special recipes:" : status == EntryStatus.NotAccessibleWithCurrentMilestones ? "Locked recipes:" : "Inaccessible recipes:"); - if (gui.BuildLink("hide")) { + gui.BuildText(status == EntryStatus.Special ? LSs.NeieSpecialRecipes : + status == EntryStatus.NotAccessibleWithCurrentMilestones ? LSs.NeieLockedRecipes : LSs.NeieInaccessibleRecipes); + + if (gui.BuildLink(LSs.NeieHideRecipes)) { ChangeShowStatus(status + 1); } } @@ -330,7 +333,7 @@ private void DrawEntryList(ImGui gui, ReadOnlySpan entries, bool pr private void BuildItemUsages(ImGui gui) => DrawEntryList(gui, usages, false); public override void Build(ImGui gui) { - BuildHeader(gui, "Never Enough Items Explorer"); + BuildHeader(gui, LSs.NeieHeader); using (gui.EnterRow()) { if (recent.Count == 0) { _ = gui.AllocateRect(0f, 3f); @@ -356,27 +359,24 @@ public override void Build(ImGui gui) { } if (gui.BuildFactorioObjectButtonBackground(gui.lastRect, current, SchemeColor.Grey) == Click.Left) { - SelectSingleObjectPanel.Select(Database.goods.explorable, "Select item", SetItem); + SelectSingleObjectPanel.Select(Database.goods.explorable, LSs.SelectItem, SetItem); } using (var split = gui.EnterHorizontalSplit(2)) { split.Next(); - gui.BuildText("Production:", Font.subheader); + gui.BuildText(LSs.NeieProduction, Font.subheader); productionList.Build(gui); split.Next(); - gui.BuildText("Usages:", Font.subheader); + gui.BuildText(LSs.NeieUsage, Font.subheader); usageList.Build(gui); } CheckChanging(); using (gui.EnterRow()) { - if (gui.BuildLink("What do colored bars mean?")) { - MessageBox.Show("How to read colored bars", - "Blue bar means estimated production or consumption of the thing you selected. Blue bar at 50% means that that recipe produces(consumes) 50% of the product.\n\n" + - "Orange bar means estimated recipe efficiency. If it is not full, the recipe looks inefficient to YAFC.\n\n" + - "It is possible for a recipe to be efficient but not useful - for example a recipe that produces something that is not useful.\n\n" + - "YAFC only estimates things that are required for science recipes. So buildings, belts, weapons, fuel - are not shown in estimations.", "Ok"); + if (gui.BuildLink(LSs.NeieColoredBarsLink)) { + MessageBox.Show(LSs.NeieHowToReadColoredBars, + LSs.NeieColoredBarsDescription, LSs.Ok); } - if (gui.BuildCheckBox("Current milestones info", atCurrentMilestones, out atCurrentMilestones, allocator: RectAllocator.RightRow)) { + if (gui.BuildCheckBox(LSs.NeieCurrentMilestonesCheckbox, atCurrentMilestones, out atCurrentMilestones, allocator: RectAllocator.RightRow)) { Refresh(); } } diff --git a/Yafc/Windows/PreferencesScreen.cs b/Yafc/Windows/PreferencesScreen.cs index b2d2cf40..db35ffdd 100644 --- a/Yafc/Windows/PreferencesScreen.cs +++ b/Yafc/Windows/PreferencesScreen.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -11,14 +12,14 @@ public class PreferencesScreen : PseudoScreen { private const int GENERAL_PAGE = 0, PROGRESSION_PAGE = 1; private readonly TabControl tabControl; - private PreferencesScreen() => tabControl = new(("General", DrawGeneral), ("Progression", DrawProgression)); + private PreferencesScreen() => tabControl = new((LSs.PreferencesTabGeneral, DrawGeneral), (LSs.PreferencesTabProgression, DrawProgression)); public override void Build(ImGui gui) { - BuildHeader(gui, "Preferences"); + BuildHeader(gui, LSs.Preferences); gui.AllocateSpacing(); tabControl.Build(gui); gui.AllocateSpacing(); - if (gui.BuildButton("Done")) { + if (gui.BuildButton(LSs.Done)) { Close(); } @@ -36,43 +37,43 @@ public override void Build(ImGui gui) { private void DrawProgression(ImGui gui) { ProjectPreferences preferences = Project.current.preferences; - ChooseObject(gui, "Default belt:", Database.allBelts, preferences.defaultBelt, s => { + ChooseObject(gui, LSs.PreferencesDefaultBelt, Database.allBelts, preferences.defaultBelt, s => { preferences.RecordUndo().defaultBelt = s; gui.Rebuild(); }); - ChooseObject(gui, "Default inserter:", Database.allInserters, preferences.defaultInserter, s => { + ChooseObject(gui, LSs.PreferencesDefaultInserter, Database.allInserters, preferences.defaultInserter, s => { preferences.RecordUndo().defaultInserter = s; gui.Rebuild(); }); using (gui.EnterRow()) { - gui.BuildText("Inserter capacity:", topOffset: 0.5f); + gui.BuildText(LSs.PreferencesInserterCapacity, topOffset: 0.5f); if (gui.BuildIntegerInput(preferences.inserterCapacity, out int newCapacity)) { preferences.RecordUndo().inserterCapacity = newCapacity; } } - ChooseObjectWithNone(gui, "Target technology for cost analysis: ", Database.technologies.all, preferences.targetTechnology, x => { + ChooseObjectWithNone(gui, LSs.PreferencesTargetTechnology, Database.technologies.all, preferences.targetTechnology, x => { preferences.RecordUndo().targetTechnology = x; gui.Rebuild(); }, width: 25f); gui.AllocateSpacing(); using (gui.EnterRow()) { - gui.BuildText("Mining productivity bonus: ", topOffset: 0.5f); + gui.BuildText(LSs.PreferencesMiningProductivityBonus, topOffset: 0.5f); DisplayAmount amount = new(Project.current.settings.miningProductivity, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value >= 0) { Project.current.settings.RecordUndo().miningProductivity = amount.Value; } } using (gui.EnterRow()) { - gui.BuildText("Research speed bonus: ", topOffset: 0.5f); + gui.BuildText(LSs.PreferencesResearchSpeedBonus, topOffset: 0.5f); DisplayAmount amount = new(Project.current.settings.researchSpeedBonus, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value >= 0) { Project.current.settings.RecordUndo().researchSpeedBonus = amount.Value; } } using (gui.EnterRow()) { - gui.BuildText("Research productivity bonus: ", topOffset: 0.5f); + gui.BuildText(LSs.PreferencesResearchProductivityBonus, topOffset: 0.5f); DisplayAmount amount = new(Project.current.settings.researchProductivity, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value >= 0) { Project.current.settings.RecordUndo().researchProductivity = amount.Value; @@ -98,7 +99,7 @@ private void DrawTechnology(ImGui gui, Technology tech, int _) { using (gui.EnterRow()) { gui.allocator = RectAllocator.LeftRow; gui.BuildFactorioObjectButton(tech, ButtonDisplayStyle.Default); - gui.BuildText($"{tech.locName} Level: "); + gui.BuildText(LSs.PreferencesTechnologyLevel.L(tech.locName)); int currentLevel = Project.current.settings.productivityTechnologyLevels.GetValueOrDefault(tech, 0); if (gui.BuildIntegerInput(currentLevel, out int newLevel) && newLevel >= 0) { Project.current.settings.RecordUndo().productivityTechnologyLevels[tech] = newLevel; @@ -111,21 +112,21 @@ private static void DrawGeneral(ImGui gui) { ProjectSettings settings = Project.current.settings; bool newValue; - gui.BuildText("Unit of time:", Font.subheader); + gui.BuildText(LSs.PrefsUnitOfTime, Font.subheader); using (gui.EnterRow()) { - if (gui.BuildRadioButton("Second", preferences.time == 1)) { + if (gui.BuildRadioButton(LSs.PrefsUnitSeconds, preferences.time == 1)) { preferences.RecordUndo(true).time = 1; } - if (gui.BuildRadioButton("Minute", preferences.time == 60)) { + if (gui.BuildRadioButton(LSs.PrefsUnitMinutes, preferences.time == 60)) { preferences.RecordUndo(true).time = 60; } - if (gui.BuildRadioButton("Hour", preferences.time == 3600)) { + if (gui.BuildRadioButton(LSs.PrefsTimeUnitHours, preferences.time == 3600)) { preferences.RecordUndo(true).time = 3600; } - if (gui.BuildRadioButton("Custom", preferences.time is not 1 and not 60 and not 3600)) { + if (gui.BuildRadioButton(LSs.PrefsTimeUnitCustom, preferences.time is not 1 and not 60 and not 3600)) { preferences.RecordUndo(true).time = 0; } @@ -134,13 +135,13 @@ private static void DrawGeneral(ImGui gui) { } } gui.AllocateSpacing(1f); - gui.BuildText("Item production/consumption:", Font.subheader); + gui.BuildText(LSs.PrefsHeaderItemUnits, Font.subheader); BuildUnitPerTime(gui, false, preferences); - gui.BuildText("Fluid production/consumption:", Font.subheader); + gui.BuildText(LSs.PrefsHeaderFluidUnits, Font.subheader); BuildUnitPerTime(gui, true, preferences); - using (gui.EnterRowWithHelpIcon("0% for off, 100% for old default")) { - gui.BuildText("Pollution cost modifier", topOffset: 0.5f); + using (gui.EnterRowWithHelpIcon(LSs.PrefsPollutionCostHint)) { + gui.BuildText(LSs.PrefsPollutionCost, topOffset: 0.5f); DisplayAmount amount = new(settings.PollutionCostModifier, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value >= 0) { settings.RecordUndo().PollutionCostModifier = amount.Value; @@ -148,10 +149,8 @@ private static void DrawGeneral(ImGui gui) { } } - 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); + using (gui.EnterRowWithHelpIcon(LSs.PrefsIconScaleHint)) { + gui.BuildText(LSs.PrefsIconScale, topOffset: 0.5f); DisplayAmount amount = new(preferences.iconScale, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput) && amount.Value > 0 && amount.Value <= 1) { preferences.RecordUndo().iconScale = amount.Value; @@ -162,10 +161,8 @@ private static void DrawGeneral(ImGui gui) { // Don't show this preference if it isn't relevant. // (Takes ~3ms for pY, which would concern me in the regular UI, but should be fine here.) if (Database.objects.all.Any(o => Milestones.Instance.GetMilestoneResult(o).PopCount() > 22)) { - string overlapMessage = "Some tooltips may want to show multiple rows of milestones. Increasing this number will draw fewer lines in some tooltips, by forcing the milestones to overlap.\n\n" - + "Minimum: 22\nDefault: 28"; - using (gui.EnterRowWithHelpIcon(overlapMessage)) { - gui.BuildText("Maximum milestones per line in tooltips:", topOffset: 0.5f); + using (gui.EnterRowWithHelpIcon(LSs.PrefsMilestonesPerLineHint)) { + gui.BuildText(LSs.PrefsMilestonesPerLine, topOffset: 0.5f); if (gui.BuildIntegerInput(preferences.maxMilestonesPerTooltipLine, out int newIntValue) && newIntValue >= 22) { preferences.RecordUndo().maxMilestonesPerTooltipLine = newIntValue; gui.Rebuild(); @@ -174,9 +171,9 @@ private static void DrawGeneral(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'); + gui.BuildText(LSs.PrefsReactorLayout, topOffset: 0.5f); + if (gui.BuildTextInput(settings.reactorSizeX + LSs.PrefsReactorXYSeparator + settings.reactorSizeY, out string newSize, null, delayed: true)) { + int px = newSize.IndexOf(LSs.PrefsReactorXYSeparator); if (px < 0 && int.TryParse(newSize, out int value)) { settings.RecordUndo().reactorSizeX = value; settings.reactorSizeY = value; @@ -188,8 +185,8 @@ private static void DrawGeneral(ImGui gui) { } } - using (gui.EnterRowWithHelpIcon("Set this to match the spoiling rate you selected when starting your game. 10% is slow spoiling, and 1000% (1k%) is fast spoiling.")) { - gui.BuildText("Spoiling rate:", topOffset: 0.5f); + using (gui.EnterRowWithHelpIcon(LSs.PrefsSpoilingRateHint)) { + gui.BuildText(LSs.PrefsSpoilingRate, topOffset: 0.5f); DisplayAmount amount = new(settings.spoilingRate, UnitOfMeasure.Percent); if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.DefaultTextInput)) { settings.RecordUndo().spoilingRate = Math.Clamp(amount.Value, .1f, 10); @@ -199,17 +196,17 @@ private static void DrawGeneral(ImGui gui) { gui.AllocateSpacing(); - if (gui.BuildCheckBox("Show milestone overlays on inaccessible objects", preferences.showMilestoneOnInaccessible, out newValue)) { + if (gui.BuildCheckBox(LSs.PrefsShowInaccessibleMilestoneOverlays, preferences.showMilestoneOnInaccessible, out newValue)) { preferences.RecordUndo().showMilestoneOnInaccessible = newValue; } - if (gui.BuildCheckBox("Dark mode", Preferences.Instance.darkMode, out newValue)) { + if (gui.BuildCheckBox(LSs.PrefsDarkMode, Preferences.Instance.darkMode, out newValue)) { Preferences.Instance.darkMode = newValue; Preferences.Instance.Save(); RenderingUtils.SetColorScheme(newValue); } - if (gui.BuildCheckBox("Enable autosave (Saves when the window loses focus)", Preferences.Instance.autosaveEnabled, out newValue)) { + if (gui.BuildCheckBox(LSs.PrefsAutosave, Preferences.Instance.autosaveEnabled, out newValue)) { Preferences.Instance.autosaveEnabled = newValue; } } @@ -243,26 +240,26 @@ private static void ChooseObjectWithNone(ImGui gui, string text, T[] list, T? 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)) { + if (gui.BuildRadioButton(LSs.PrefsGoodsUnitSimple.L(preferences.GetPerTimeUnit().suffix), unit == 0f)) { unit = 0f; } using (gui.EnterRow()) { - if (gui.BuildRadioButton("Custom: 1 unit equals", unit != 0f)) { + if (gui.BuildRadioButton(LSs.PrefsGoodsUnitCustom, unit != 0f)) { unit = 1f; } gui.AllocateSpacing(); gui.allocator = RectAllocator.RightRow; if (!fluid) { - if (gui.BuildButton("Set from belt")) { + if (gui.BuildButton(LSs.PrefsGoodsUnitFromBelt)) { gui.BuildObjectSelectDropDown(Database.allBelts, setBelt => { _ = preferences.RecordUndo(true); preferences.itemUnit = setBelt.beltItemsPerSecond; - }, new("Select belt", DataUtils.DefaultOrdering, ExtraText: b => DataUtils.FormatAmount(b.beltItemsPerSecond, UnitOfMeasure.PerSecond))); + }, new(LSs.PrefsSelectBelt, DataUtils.DefaultOrdering, ExtraText: b => DataUtils.FormatAmount(b.beltItemsPerSecond, UnitOfMeasure.PerSecond))); } } - gui.BuildText("per second"); + gui.BuildText(LSs.PerSecondSuffixLong); _ = gui.BuildFloatInput(unit, TextBoxDisplayStyle.DefaultTextInput); } gui.AllocateSpacing(1f); diff --git a/Yafc/Windows/ProjectPageSettingsPanel.cs b/Yafc/Windows/ProjectPageSettingsPanel.cs index e9080cb7..67f3bfe1 100644 --- a/Yafc/Windows/ProjectPageSettingsPanel.cs +++ b/Yafc/Windows/ProjectPageSettingsPanel.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; using System.Text; using System.Text.Json; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -26,13 +28,13 @@ private ProjectPageSettingsPanel(ProjectPage? editingPage, Action setIcon) { - _ = gui.BuildTextInput(name, out name, "Input name", setKeyboardFocus: editingPage == null ? SetKeyboardFocus.OnFirstPanelDraw : SetKeyboardFocus.No); + _ = gui.BuildTextInput(name, out name, LSs.PageSettingsNameHint, setKeyboardFocus: editingPage == null ? SetKeyboardFocus.OnFirstPanelDraw : SetKeyboardFocus.No); if (gui.BuildFactorioObjectButton(icon, new ButtonDisplayStyle(4f, MilestoneDisplay.None, SchemeColor.Grey) with { UseScaleSetting = false }) == Click.Left) { - SelectSingleObjectPanel.Select(Database.objects.all, "Select icon", setIcon); + SelectSingleObjectPanel.Select(Database.objects.all, LSs.SelectIcon, setIcon); } if (icon == null && gui.isBuilding) { - gui.DrawText(gui.lastRect, "And select icon", RectAlignment.Middle); + gui.DrawText(gui.lastRect, LSs.PageSettingsIconHint, RectAlignment.Middle); } } @@ -40,31 +42,31 @@ private void Build(ImGui gui, Action setIcon) { public override void Build(ImGui gui) { gui.spacing = 3f; - BuildHeader(gui, editingPage == null ? "Create new page" : "Edit page icon and name"); + BuildHeader(gui, editingPage == null ? LSs.PageSettingsCreateHeader : LSs.PageSettingsEditHeader); Build(gui, s => { icon = s; Rebuild(); }); using (gui.EnterRow(0.5f, RectAllocator.RightRow)) { - if (editingPage == null && gui.BuildButton("Create", active: !string.IsNullOrEmpty(name))) { + if (editingPage == null && gui.BuildButton(LSs.Create, active: !string.IsNullOrEmpty(name))) { ReturnPressed(); } - if (editingPage != null && gui.BuildButton("OK", active: !string.IsNullOrEmpty(name))) { + if (editingPage != null && gui.BuildButton(LSs.Ok, active: !string.IsNullOrEmpty(name))) { ReturnPressed(); } - if (gui.BuildButton("Cancel", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.Cancel, SchemeColor.Grey)) { Close(); } - if (editingPage != null && gui.BuildButton("Other tools", SchemeColor.Grey, active: !string.IsNullOrEmpty(name))) { + if (editingPage != null && gui.BuildButton(LSs.PageSettingsOther, SchemeColor.Grey, active: !string.IsNullOrEmpty(name))) { gui.ShowDropDown(OtherToolsDropdown); } gui.allocator = RectAllocator.LeftRow; - if (editingPage != null && gui.BuildRedButton("Delete page")) { + if (editingPage != null && gui.BuildRedButton(LSs.DeletePage)) { if (editingPage.canDelete) { Project.current.RemovePage(editingPage); } @@ -93,7 +95,7 @@ protected override void ReturnPressed() { } private void OtherToolsDropdown(ImGui gui) { - if (editingPage!.guid != MainScreen.SummaryGuid && gui.BuildContextMenuButton("Duplicate page")) { // null-forgiving: This dropdown is not shown when editingPage is null. + if (editingPage!.guid != MainScreen.SummaryGuid && gui.BuildContextMenuButton(LSs.DuplicatePage)) { // null-forgiving: This dropdown is not shown when editingPage is null. _ = gui.CloseDropdown(); var project = editingPage.owner; if (ClonePage(editingPage) is { } serializedCopy) { @@ -105,7 +107,7 @@ private void OtherToolsDropdown(ImGui gui) { } } - if (editingPage.guid != MainScreen.SummaryGuid && gui.BuildContextMenuButton("Share (export string to clipboard)")) { + if (editingPage.guid != MainScreen.SummaryGuid && gui.BuildContextMenuButton(LSs.ExportPageToClipboard)) { _ = gui.CloseDropdown(); var data = JsonUtils.SaveToJson(editingPage); using MemoryStream targetStream = new MemoryStream(); @@ -122,14 +124,14 @@ private void OtherToolsDropdown(ImGui gui) { _ = SDL.SDL_SetClipboardText(encoded); } - if (editingPage == MainScreen.Instance.activePage && gui.BuildContextMenuButton("Make full page screenshot")) { + if (editingPage == MainScreen.Instance.activePage && gui.BuildContextMenuButton(LSs.PageSettingsScreenshot)) { // 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(); } - if (gui.BuildContextMenuButton("Export calculations (to clipboard)")) { + if (gui.BuildContextMenuButton(LSs.PageSettingsExportCalculations)) { ExportPage(editingPage); _ = gui.CloseDropdown(); } @@ -165,12 +167,12 @@ private class ExportRecipe { public ExportRecipe(RecipeRow row) { Recipe = row.recipe.QualityName(); - Building = row.entity; + Building = ObjectWithQuality.Get(row.entity); BuildingCount = row.buildingCount; - Fuel = new ExportMaterial(row.fuel?.QualityName() ?? "", row.FuelInformation.Amount); - Inputs = row.Ingredients.Select(i => new ExportMaterial(i.Goods?.QualityName() ?? "Recipe disabled", i.Amount)); - Outputs = row.Products.Select(p => new ExportMaterial(p.Goods?.QualityName() ?? "Recipe disabled", p.Amount)); - Beacon = row.usedModules.beacon; + Fuel = new ExportMaterial(row.fuel?.QualityName() ?? LSs.ExportNoFuelSelected, row.FuelInformation.Amount); + Inputs = row.Ingredients.Select(i => new ExportMaterial(i.Goods?.QualityName() ?? LSs.ExportRecipeDisabled, i.Amount)); + Outputs = row.Products.Select(p => new ExportMaterial(p.Goods?.QualityName() ?? LSs.ExportRecipeDisabled, p.Amount)); + Beacon = ObjectWithQuality.Get(row.usedModules.beacon); BeaconCount = row.usedModules.beaconCount; if (row.usedModules.modules is null) { @@ -182,10 +184,10 @@ public ExportRecipe(RecipeRow row) { foreach (var (module, count, isBeacon) in row.usedModules.modules) { if (isBeacon) { - beaconModules.AddRange(Enumerable.Repeat(module, count)); + beaconModules.AddRange(Enumerable.Repeat(ObjectWithQuality.Get(module).Value, count)); } else { - modules.AddRange(Enumerable.Repeat(module, count)); + modules.AddRange(Enumerable.Repeat(ObjectWithQuality.Get(module).Value, count)); } } @@ -230,19 +232,19 @@ public static void LoadProjectPageFromClipboard() { Version version = new Version(DataUtils.ReadLine(bytes, ref index) ?? ""); if (version > YafcLib.version) { - collector.Error("String was created with the newer version of YAFC (" + version + "). Data may be lost.", ErrorSeverity.Important); + collector.Error(LSs.AlertImportPageNewerVersion.L(version), ErrorSeverity.Important); } _ = DataUtils.ReadLine(bytes, ref index); // reserved 1 if (DataUtils.ReadLine(bytes, ref index) != "") // reserved 2 but this time it is required to be empty { - throw new NotSupportedException("Share string was created with future version of YAFC (" + version + ") and is incompatible"); + throw new NotSupportedException(LSs.AlertImportPageIncompatibleVersion.L(version)); } page = JsonUtils.LoadFromJson(new ReadOnlySpan(bytes, index, (int)ms.Length - index), project, collector); } catch (Exception ex) { - collector.Exception(ex, "Clipboard text does not contain valid YAFC share string", ErrorSeverity.Critical); + collector.Exception(ex, LSs.AlertImportPageInvalidString, ErrorSeverity.Critical); } if (page != null) { @@ -262,8 +264,8 @@ public static void LoadProjectPageFromClipboard() { project.RecordUndo().pages.Add(page); MainScreen.Instance.SetActivePage(page); - }, "Page already exists", - "Looks like this page already exists with name '" + existing.name + "'. Would you like to replace it or import as copy?", "Replace", "Import as copy"); + }, LSs.ImportPageAlreadyExists, + LSs.ImportPageAlreadyExistsLong.L(existing.name), LSs.Replace, LSs.ImportAsCopy); } else { project.RecordUndo().pages.Add(page); @@ -277,8 +279,8 @@ public static void LoadProjectPageFromClipboard() { } private record struct ObjectWithQuality(string Name, string Quality) { - public static implicit operator ObjectWithQuality?(ObjectWithQuality? value) => value == null ? default(ObjectWithQuality?) : new ObjectWithQuality(value.target.name, value.quality.name); - public static implicit operator ObjectWithQuality(ObjectWithQuality value) => new ObjectWithQuality(value.target.name, value.quality.name); - public static implicit operator ObjectWithQuality?(ObjectWithQuality? value) => value == null ? default(ObjectWithQuality?) : new ObjectWithQuality(value.target.name, value.quality.name); + [return: NotNullIfNotNull(nameof(objectWithQuality))] + public static ObjectWithQuality? Get(IObjectWithQuality? objectWithQuality) + => objectWithQuality == null ? default : new(objectWithQuality.target.name, objectWithQuality.quality.name); } } diff --git a/Yafc/Windows/SelectMultiObjectPanel.cs b/Yafc/Windows/SelectMultiObjectPanel.cs index ae87beb3..19630a7c 100644 --- a/Yafc/Windows/SelectMultiObjectPanel.cs +++ b/Yafc/Windows/SelectMultiObjectPanel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -40,7 +41,7 @@ public static void Select(IEnumerable list, string? header, Action sele /// 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. /// An optional ordering specifying how to sort the displayed items. If , defaults to . - public static void SelectWithQuality(IEnumerable list, string header, Action> selectItem, Quality currentQuality, + public static void SelectWithQuality(IEnumerable list, string header, Action> selectItem, Quality currentQuality, IComparer? ordering = null, Predicate? checkMark = null, Predicate? yellowMark = null) where T : FactorioObject { SelectMultiObjectPanel panel = new(o => checkMark?.Invoke((T)o) ?? false, o => yellowMark?.Invoke((T)o) ?? false); // This casting is messy, but pushing T all the way around the call stack and type tree was messier. @@ -76,10 +77,10 @@ protected override void NonNullElementDrawer(ImGui gui, FactorioObject element) public override void Build(ImGui gui) { base.Build(gui); using (gui.EnterGroup(default, RectAllocator.Center)) { - if (gui.BuildButton("OK")) { + if (gui.BuildButton(LSs.Ok)) { CloseWithResult(results); } - gui.BuildText("Hint: ctrl+click to select multiple", TextBlockDisplayStyle.HintText); + gui.BuildText(LSs.SelectMultipleObjectsHint, TextBlockDisplayStyle.HintText); } } diff --git a/Yafc/Windows/SelectObjectPanel.cs b/Yafc/Windows/SelectObjectPanel.cs index 7c784262..f55f0bf2 100644 --- a/Yafc/Windows/SelectObjectPanel.cs +++ b/Yafc/Windows/SelectObjectPanel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Numerics; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -25,9 +26,9 @@ public abstract class SelectObjectPanel : PseudoScreenWithResult { protected SelectObjectPanel() : base(40f) => list = new SearchableList(30, new Vector2(2.5f, 2.5f), ElementDrawer, ElementFilter); - protected void SelectWithQuality(IEnumerable list, string? header, Action?> selectItem, IComparer? ordering, Action> mapResult, + protected void SelectWithQuality(IEnumerable list, string? header, Action?> selectItem, IComparer? ordering, Action> mapResult, bool allowNone, string? noneTooltip, Quality? currentQuality) where U : FactorioObject - => Select(list, header, u => selectItem((u, this.currentQuality!)), ordering, mapResult, allowNone, noneTooltip, currentQuality ?? Quality.Normal); + => Select(list, header, u => selectItem(u.With(this.currentQuality!)), ordering, mapResult, allowNone, noneTooltip, currentQuality ?? Quality.Normal); /// /// Opens a to allow the user to select zero or more s. @@ -111,7 +112,7 @@ public override void Build(ImGui gui) { gui.AllocateSpacing(); } - if (gui.BuildSearchBox(list.filter, out var newFilter, "Start typing for search", setKeyboardFocus: SetKeyboardFocus.OnFirstPanelDraw)) { + if (gui.BuildSearchBox(list.filter, out var newFilter, LSs.TypeForSearchHint, setKeyboardFocus: SetKeyboardFocus.OnFirstPanelDraw)) { list.filter = newFilter; } diff --git a/Yafc/Windows/SelectSingleObjectPanel.cs b/Yafc/Windows/SelectSingleObjectPanel.cs index 1638f86c..704bd449 100644 --- a/Yafc/Windows/SelectSingleObjectPanel.cs +++ b/Yafc/Windows/SelectSingleObjectPanel.cs @@ -32,7 +32,7 @@ public static void Select(IEnumerable list, string? header, Action sele /// 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 . - public static void SelectWithQuality(IEnumerable list, string header, Action> selectItem, Quality? currentQuality, + public static void SelectWithQuality(IEnumerable list, string header, Action> selectItem, Quality? currentQuality, IComparer? ordering = null) where T : FactorioObject // null-forgiving: selectItem will not be called with null, because allowNone is false. => Instance.SelectWithQuality(list, header, selectItem!, ordering, (obj, mappedAction) => mappedAction(obj), false, null, currentQuality); @@ -60,7 +60,7 @@ public static void SelectWithNone(IEnumerable list, string? header, Action /// 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 SelectQualityWithNone(IEnumerable list, string? header, Action?> selectItem, Quality? currentQuality, IComparer? ordering = null, + public static void SelectQualityWithNone(IEnumerable list, string? header, Action?> selectItem, Quality? currentQuality, IComparer? ordering = null, string? noneTooltip = null) where T : FactorioObject => Instance.SelectWithQuality(list, header, selectItem, ordering, (obj, mappedAction) => mappedAction(obj), true, noneTooltip, currentQuality); diff --git a/Yafc/Windows/ShoppingListScreen.cs b/Yafc/Windows/ShoppingListScreen.cs index 0859ab8e..2967842a 100644 --- a/Yafc/Windows/ShoppingListScreen.cs +++ b/Yafc/Windows/ShoppingListScreen.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Numerics; using Yafc.Blueprints; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -44,7 +45,7 @@ private ShoppingListScreen(List recipes) : base(42) { private void ElementDrawer(ImGui gui, (IObjectWithQuality obj, float count) element, int index) { using (gui.EnterRow()) { gui.BuildFactorioObjectIcon(element.obj, new IconDisplayStyle(2, MilestoneDisplay.Contained, false)); - gui.RemainingRow().BuildText(DataUtils.FormatAmount(element.count, UnitOfMeasure.None, "x") + ": " + element.obj.target.locName); + gui.RemainingRow().BuildText(LSs.ShoppingListCountOfItems.L(DataUtils.FormatAmount(element.count, UnitOfMeasure.None), element.obj.target.locName)); } _ = gui.BuildFactorioObjectButtonBackground(gui.lastRect, element.obj); } @@ -59,7 +60,7 @@ private void RebuildData() { Dictionary, int> counts = []; foreach (RecipeRow recipe in recipes) { if (recipe.entity != null) { - ObjectWithQuality shopItem = new(recipe.entity.target.itemsToPlace?.FirstOrDefault() ?? (FactorioObject)recipe.entity.target, recipe.entity.quality); + IObjectWithQuality shopItem = (recipe.entity.target.itemsToPlace?.FirstOrDefault() ?? (FactorioObject)recipe.entity.target).With(recipe.entity.quality); _ = counts.TryGetValue(shopItem, out int prev); int builtCount = recipe.builtBuildings ?? (assumeAdequate ? MathUtils.Ceil(recipe.buildingCount) : 0); int displayCount = displayState switch { @@ -71,7 +72,7 @@ private void RebuildData() { totalHeat += recipe.entity.target.heatingPower * displayCount; counts[shopItem] = prev + displayCount; if (recipe.usedModules.modules != null) { - foreach ((ObjectWithQuality module, int moduleCount, bool beacon) in recipe.usedModules.modules) { + foreach ((IObjectWithQuality module, int moduleCount, bool beacon) in recipe.usedModules.modules) { if (!beacon) { _ = counts.TryGetValue(module, out prev); counts[module] = prev + displayCount * moduleCount; @@ -109,29 +110,29 @@ private void RebuildData() { .ToList(); } - private static readonly (string, string?)[] displayStateOptions = [ - ("Total buildings", "Display the total number of buildings required, ignoring the built building count."), - ("Built buildings", "Display the number of buildings that are reported in built building count."), - ("Missing buildings", "Display the number of additional buildings that need to be built.")]; - private static readonly (string, string?)[] assumeAdequateOptions = [ - ("No buildings", "When the built building count is not specified, behave as if it was set to 0."), - ("Enough buildings", "When the built building count is not specified, behave as if it matches the required building count.")]; + private static readonly (LocalizableString0, LocalizableString0?)[] displayStateOptionKeys = [ + (LSs.ShoppingListTotalBuildings, LSs.ShoppingListTotalBuildingsHint), + (LSs.ShoppingListBuiltBuildings, LSs.ShoppingListBuiltBuildingsHint), + (LSs.ShoppingListMissingBuildings, LSs.ShoppingListMissingBuildingsHint)]; + private static readonly (LocalizableString0, LocalizableString0?)[] assumeAdequateOptionKeys = [ + (LSs.ShoppingListAssumeNoBuildings, LSs.ShoppingListAssumeNoBuildingsHint), + (LSs.ShoppingListAssumeEnoughBuildings, LSs.ShoppingListAssumeEnoughBuildingsHint)]; public override void Build(ImGui gui) { - BuildHeader(gui, "Shopping list"); - gui.BuildText( - "Total cost of all objects: " + DataUtils.FormatAmount(shoppingCost, UnitOfMeasure.None, "¥") + ", buildings: " + - DataUtils.FormatAmount(totalBuildings, UnitOfMeasure.None) + ", modules: " + DataUtils.FormatAmount(totalModules, UnitOfMeasure.None), TextBlockDisplayStyle.Centered); + BuildHeader(gui, LSs.ShoppingList); + gui.BuildText(LSs.ShoppingListCostInformation.L(DataUtils.FormatAmount(shoppingCost, UnitOfMeasure.None), + DataUtils.FormatAmount(totalBuildings, UnitOfMeasure.None), DataUtils.FormatAmount(totalModules, UnitOfMeasure.None)), + TextBlockDisplayStyle.Centered); using (gui.EnterRow()) { - if (gui.BuildRadioGroup(displayStateOptions, (int)displayState, out int newSelected)) { + if (gui.BuildRadioGroup(displayStateOptionKeys, (int)displayState, out int newSelected)) { displayState = (DisplayState)newSelected; RebuildData(); } } using (gui.EnterRow()) { SchemeColor textColor = displayState == DisplayState.Total ? SchemeColor.PrimaryTextFaint : SchemeColor.PrimaryText; - gui.BuildText("When not specified, assume:", TextBlockDisplayStyle.Default(textColor), topOffset: .15f); - if (gui.BuildRadioGroup(assumeAdequateOptions, assumeAdequate ? 1 : 0, out int newSelected, enabled: displayState != DisplayState.Total)) { + gui.BuildText(LSs.ShoppingListBuildingAssumptionHeader, TextBlockDisplayStyle.Default(textColor), topOffset: .15f); + if (gui.BuildRadioGroup(assumeAdequateOptionKeys, assumeAdequate ? 1 : 0, out int newSelected, enabled: displayState != DisplayState.Total)) { assumeAdequate = newSelected == 1; RebuildData(); } @@ -142,42 +143,40 @@ public override void Build(ImGui gui) { if (totalHeat > 0) { using (gui.EnterRow(0)) { gui.AllocateRect(0, 1.5f); - gui.BuildText("These entities require " + DataUtils.FormatAmount(totalHeat, UnitOfMeasure.Megawatt)); - gui.BuildFactorioObjectIcon(Database.heat, IconDisplayStyle.Default with { Size = 1.5f }); - gui.BuildText("heat on cold planets."); + gui.BuildText(LSs.ShoppingListTheseRequireHeat.L(DataUtils.FormatAmount(totalHeat, UnitOfMeasure.Megawatt))); } using (gui.EnterRow(0)) { - gui.BuildText("Allow additional heat for "); - if (gui.BuildLink("inserters")) { + gui.BuildText(LSs.ShoppingListAllowAdditionalHeat); + if (gui.BuildLink(LSs.ShoppingListHeatForInserters)) { gui.BuildObjectSelectDropDown(Database.allInserters, _ => { }, options); } - gui.BuildText(", "); - if (gui.BuildLink("pipes")) { + gui.BuildText(LSs.ListSeparator); + if (gui.BuildLink(LSs.ShoppingListHeatForPipes)) { gui.BuildObjectSelectDropDown(pipes, _ => { }, options); } - gui.BuildText(", "); - if (gui.BuildLink("belts")) { + gui.BuildText(LSs.ListSeparator); + if (gui.BuildLink(LSs.ShoppingListHeatForBelts)) { gui.BuildObjectSelectDropDown(belts, _ => { }, options); } - gui.BuildText(", and "); - if (gui.BuildLink("other entities")) { + gui.BuildText(LSs.ShoppingListHeatAnd); + if (gui.BuildLink(LSs.ShoppingListHeatForOtherEntities)) { gui.BuildObjectSelectDropDown(other, _ => { }, options); } - gui.BuildText("."); + gui.BuildText(LSs.ShoppingListHeatPeriod); } } using (gui.EnterRow(allocator: RectAllocator.RightRow)) { - if (gui.BuildButton("Done")) { + if (gui.BuildButton(LSs.Done)) { Close(); } - if (gui.BuildButton("Decompose", active: !decomposed)) { + if (gui.BuildButton(LSs.ShoppingListDecompose, active: !decomposed)) { Decompose(); } - if (gui.BuildButton("Export to blueprint", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.ShoppingListExportBlueprint, SchemeColor.Grey)) { gui.ShowDropDown(ExportBlueprintDropdown); } } @@ -191,11 +190,11 @@ public override void Build(ImGui gui) { continue; } - if (element.Is(out IObjectWithQuality? g)) { + if (element is IObjectWithQuality g) { items.Add((g, rounded)); } - else if (element.Is(out IObjectWithQuality? e) && e.target.itemsToPlace.Length > 0) { - items.Add((new ObjectWithQuality((T)(object)e.target.itemsToPlace[0], e.quality), rounded)); + else if (element is IObjectWithQuality e && e.target.itemsToPlace.Length > 0) { + items.Add((((T)(object)e.target.itemsToPlace[0]).With(e.quality), rounded)); } } @@ -203,16 +202,16 @@ public override void Build(ImGui gui) { } private void ExportBlueprintDropdown(ImGui gui) { - gui.BuildText("Blueprint string will be copied to clipboard", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ShoppingListExportBlueprintHint, TextBlockDisplayStyle.WrappedText); if (Database.objectsByTypeName.TryGetValue("Entity.constant-combinator", out var combinator) && gui.BuildFactorioObjectButtonWithText(combinator) == Click.Left && gui.CloseDropdown()) { - _ = BlueprintUtilities.ExportConstantCombinators("Shopping list", ExportGoods()); + _ = BlueprintUtilities.ExportConstantCombinators(LSs.ShoppingList, ExportGoods()); } foreach (var container in Database.allContainers) { if (container.logisticMode == "requester" && gui.BuildFactorioObjectButtonWithText(container) == Click.Left && gui.CloseDropdown()) { - _ = BlueprintUtilities.ExportRequesterChests("Shopping list", ExportGoods(), container); + _ = BlueprintUtilities.ExportRequesterChests(LSs.ShoppingList, ExportGoods(), container); } } } @@ -238,7 +237,7 @@ private void Decompose() { Dictionary, float> decomposeResult = []; void addDecomposition(FactorioObject obj, Quality quality, float amount) { - ObjectWithQuality key = new(obj, quality); + IObjectWithQuality key = obj.With(quality); if (!decomposeResult.TryGetValue(key, out float prev)) { decompositionQueue.Enqueue(key); } diff --git a/Yafc/Windows/WelcomeScreen.cs b/Yafc/Windows/WelcomeScreen.cs index 7e89cecf..c49a9585 100644 --- a/Yafc/Windows/WelcomeScreen.cs +++ b/Yafc/Windows/WelcomeScreen.cs @@ -1,20 +1,46 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Compression; using System.Linq; +using System.Net.Http; +using System.Reflection; using System.Runtime.InteropServices; +using Newtonsoft.Json.Linq; using SDL2; using Serilog; +using Yafc.I18n; using Yafc.Model; using Yafc.Parser; using Yafc.UI; namespace Yafc; +/// +/// Contains information about a language supported by Factorio. +/// +/// The name of the language, expressed in its language. To select this language, the current font must contain glyphs +/// for all characters in this string. +/// , if that uses only well-supported Latin characters, or the English name of the +/// language. +/// The base name of the default font to use for this language. +internal sealed record LanguageInfo(string LatinName, string NativeName, string BaseFontName) { + public LanguageInfo(string latinName, string nativeName) : this(latinName, nativeName, "Roboto") { } + public LanguageInfo(string name) : this(name, name, "Roboto") { } + + /// + /// If not , the current font must also have glyphs for these characters. Some fonts have enough glyphs for the + /// language name, but don't have glyphs for all characters used by the language. (e.g. Some fonts have glyphs for türkmençe but are missing + /// the Turkish ı.) + /// + public string? CheckExtraCharacters { get; set; } +} + public class WelcomeScreen : WindowUtility, IProgress<(string, string)>, IKeyboardFocus { private readonly ILogger logger = Logging.GetLogger(); - private bool loading; + private bool loading, downloading; private string? currentLoad1, currentLoad2; private string path = "", dataPath = "", _modsPath = ""; private string modsPath { @@ -36,37 +62,56 @@ private string modsPath { private readonly string[] tips; private bool useMostRecentSave = true; - private static readonly Dictionary languageMapping = new Dictionary() + internal static readonly SortedList languageMapping = new() { - {"en", "English"}, - {"ca", "Catalan"}, - {"cs", "Czech"}, - {"da", "Danish"}, - {"nl", "Dutch"}, - {"de", "German"}, - {"fi", "Finnish"}, - {"fr", "French"}, - {"hu", "Hungarian"}, - {"it", "Italian"}, - {"no", "Norwegian"}, - {"pl", "Polish"}, - {"pt-PT", "Portuguese"}, - {"pt-BR", "Portuguese (Brazilian)"}, - {"ro", "Romanian"}, - {"ru", "Russian"}, - {"es-ES", "Spanish"}, - {"sv-SE", "Swedish"}, - {"tr", "Turkish"}, - {"uk", "Ukrainian"}, - }; - - private static readonly Dictionary languagesRequireFontOverride = new Dictionary() - { - {"ja", "Japanese"}, - {"zh-CN", "Chinese (Simplified)"}, - {"zh-TW", "Chinese (Traditional)"}, - {"ko", "Korean"}, - {"tr", "Turkish"}, + {"en", new("English")}, + {"af", new("Afrikaans")}, + {"ar", new("Arabic", "العربية", "noto-sans-arabic")}, + {"be", new("Belarusian", "беларуская")}, + {"bg", new("Bulgarian", "български")}, + {"ca", new("català")}, + {"cs", new("Czech", "čeština")}, + {"da", new("dansk")}, + {"de", new("Deutsch")}, + {"el", new("Greek", "Ελληνικά")}, + {"es-ES", new("español")}, + {"et", new("eesti") }, + {"eu", new("euskara") }, + {"fi", new("suomi")}, + {"fil", new("Filipino")}, + {"fr", new("français")}, + {"fy-NL", new("Frysk")}, + {"ga-IE", new("Gaeilge")}, + {"he", new("Hebrew", "עברית", "noto-sans-hebrew")}, + {"hr", new("hrvatski")}, + {"hu", new("magyar")}, + {"id", new("Bahasa Indonesia")}, + {"is", new("íslenska")}, + {"it", new("italiano")}, + {"ja", new("Japanese", "日本語", "noto-sans-jp") { CheckExtraCharacters = "軽気処鉱砕" }}, + {"ka", new("Georgian", "ქართული", "noto-sans-georgian")}, + {"kk", new("Kazakh", "Қазақша")}, + {"ko", new("Korean", "한국어", "noto-sans-kr")}, + {"lt", new("Lithuanian", "lietuvių")}, + {"lv", new("Latvian", "latviešu")}, + {"nl", new("Nederlands")}, + {"no", new("norsk")}, + {"pl", new("polski")}, + {"pt-PT", new("português")}, + {"pt-BR", new("português (Brazil)")}, + {"ro", new("română")}, + {"ru", new("Russian", "русский")}, + {"sk", new("Slovak", "slovenčina")}, + {"sl", new("slovenski")}, + {"sq", new("shqip")}, + {"sr", new("Serbian", "српски")}, + {"sv-SE", new("svenska")}, + {"th", new("Thai", "ไทย", "noto-sans-thai")}, + {"tr", new("Turkish","türkmençe") { CheckExtraCharacters = "ığş" }}, + {"uk", new("Ukranian", "українська")}, + {"vi", new("Vietnamese", "Tiếng Việt")}, + {"zh-CN", new("Chinese (Simplified)", "汉语", "noto-sans-sc")}, + {"zh-TW", new("Chinese (Traditional)", "漢語", "noto-sans-tc") { CheckExtraCharacters = "鈾礦" }}, }; private enum EditType { @@ -82,7 +127,7 @@ public WelcomeScreen(ProjectDefinition? cliProject = null) : base(ImGuiUtils.Def recentProjectScroll = new ScrollArea(20f, BuildRecentProjectList, collapsible: true); languageScroll = new ScrollArea(20f, LanguageSelection, collapsible: true); errorScroll = new ScrollArea(20f, BuildError, collapsible: true); - Create("Welcome to YAFC CE v" + YafcLib.version.ToString(3), 45, null); + Create(LSs.Welcome.L(YafcLib.version.ToString(3)), 45, null); if (cliProject != null && !string.IsNullOrEmpty(cliProject.dataPath)) { SetProject(cliProject); @@ -97,7 +142,7 @@ public WelcomeScreen(ProjectDefinition? cliProject = null) : base(ImGuiUtils.Def private void BuildError(ImGui gui) { if (errorMod != null) { - gui.BuildText($"Error while loading mod {errorMod}.", TextBlockDisplayStyle.Centered with { Color = SchemeColor.Error }); + gui.BuildText(LSs.ErrorWhileLoadingMod.L(errorMod), TextBlockDisplayStyle.Centered with { Color = SchemeColor.Error }); } gui.allocator = RectAllocator.Stretch; @@ -107,7 +152,7 @@ private void BuildError(ImGui gui) { protected override void BuildContents(ImGui gui) { gui.spacing = 1.5f; - gui.BuildText("Yet Another Factorio Calculator", new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); + gui.BuildText(LSs.FullName, new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); if (loading) { gui.BuildText(currentLoad1, TextBlockDisplayStyle.Centered); gui.BuildText(currentLoad2, TextBlockDisplayStyle.Centered); @@ -115,84 +160,80 @@ protected override void BuildContents(ImGui gui) { gui.BuildText(tip, new TextBlockDisplayStyle(WrapText: true, Alignment: RectAlignment.Middle)); gui.SetNextRebuild(Ui.time + 30); } + else if (downloading) { + gui.BuildText(LSs.PleaseWait, TextBlockDisplayStyle.Centered); + gui.BuildText(LSs.DownloadingFonts, TextBlockDisplayStyle.Centered); + } else if (errorMessage != null) { errorScroll.Build(gui); bool thereIsAModToDisable = (errorMod != null); using (gui.EnterRow()) { if (thereIsAModToDisable) { - gui.BuildWrappedText("YAFC was unable to load the project. You can disable the problematic mod once by clicking on 'Disable & reload' button, or you can disable it " + - "permanently for YAFC by copying the mod-folder, disabling the mod in the copy by editing mod-list.json, and pointing YAFC to the copy."); + gui.BuildWrappedText(LSs.UnableToLoadWithMod); } else { - gui.BuildWrappedText("YAFC cannot proceed because it was unable to load the project."); + gui.BuildWrappedText(LSs.UnableToLoad); } } using (gui.EnterRow()) { - if (gui.BuildLink("More info")) { + if (gui.BuildLink(LSs.MoreInfo)) { ShowDropDown(gui, gui.lastRect, ProjectErrorMoreInfo, new Padding(0.5f), 30f); } } using (gui.EnterRow()) { - if (gui.BuildButton("Copy to clipboard", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.CopyToClipboard, SchemeColor.Grey)) { _ = SDL.SDL_SetClipboardText(errorMessage); } - if (thereIsAModToDisable && gui.BuildButton("Disable & reload").WithTooltip(gui, "Disable this mod until you close YAFC or change the mod folder.")) { + if (thereIsAModToDisable && gui.BuildButton(LSs.DisableAndReload).WithTooltip(gui, LSs.DisableAndReloadHint)) { FactorioDataSource.DisableMod(errorMod!); errorMessage = null; LoadProject(); } - if (gui.RemainingRow().BuildButton("Back")) { + if (gui.RemainingRow().BuildButton(LSs.BackButton)) { errorMessage = null; Rebuild(); } } } else { - BuildPathSelect(gui, path, "Project file location", "You can leave it empty for a new project", EditType.Workspace); - BuildPathSelect(gui, dataPath, "Factorio Data location*\nIt should contain folders 'base' and 'core'", - "e.g. C:/Games/Steam/SteamApps/common/Factorio/data", EditType.Factorio); - BuildPathSelect(gui, modsPath, "Factorio Mods location (optional)\nIt should contain file 'mod-list.json'", - "If you don't use separate mod folder, leave it empty", EditType.Mods); + BuildPathSelect(gui, path, LSs.WelcomeProjectFileLocation, LSs.WelcomeProjectFileLocationHint, EditType.Workspace); + BuildPathSelect(gui, dataPath, LSs.WelcomeDataLocation, + LSs.WelcomeDataLocationHint, EditType.Factorio); + BuildPathSelect(gui, modsPath, LSs.WelcomeModLocation, + LSs.WelcomeModLocationHint, EditType.Mods); using (gui.EnterRow()) { gui.allocator = RectAllocator.RightRow; string lang = Preferences.Instance.language; - if (languageMapping.TryGetValue(Preferences.Instance.language, out string? mapped) || languagesRequireFontOverride.TryGetValue(Preferences.Instance.language, out mapped)) { - lang = mapped; + if (languageMapping.TryGetValue(Preferences.Instance.language, out LanguageInfo? mapped)) { + lang = mapped.NativeName; } if (gui.BuildLink(lang)) { gui.ShowDropDown(languageScroll.Build); } - gui.BuildText("In-game objects language:"); + gui.BuildText(LSs.WelcomeLanguageHeader); } - using (gui.EnterRowWithHelpIcon("""When enabled it will try to find a more recent autosave. Disable if you want to load your manual save only.""", false)) { - if (gui.BuildCheckBox("Load most recent (auto-)save", Preferences.Instance.useMostRecentSave, + using (gui.EnterRowWithHelpIcon(LSs.WelcomeLoadAutosaveHint, false)) { + if (gui.BuildCheckBox(LSs.WelcomeLoadAutosave, Preferences.Instance.useMostRecentSave, out useMostRecentSave)) { Preferences.Instance.useMostRecentSave = useMostRecentSave; Preferences.Instance.Save(); } } - using (gui.EnterRowWithHelpIcon(""" - If checked, YAFC will only suggest production or consumption recipes that have a net production or consumption of that item or fluid. - For example, kovarex enrichment will not be suggested when adding recipes that produce U-238 or consume U-235. - """, false)) { - _ = gui.BuildCheckBox("Use net production/consumption when analyzing recipes", netProduction, out netProduction); + using (gui.EnterRowWithHelpIcon(LSs.WelcomeUseNetProductionHint, false)) { + _ = gui.BuildCheckBox(LSs.WelcomeUseNetProduction, netProduction, out netProduction); } - 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)) { + using (gui.EnterRowWithHelpIcon(LSs.WelcomeSoftwareRenderHint, false)) { bool forceSoftwareRenderer = Preferences.Instance.forceSoftwareRenderer; - _ = gui.BuildCheckBox("Force software rendering in project screen", forceSoftwareRenderer, out forceSoftwareRenderer); + _ = gui.BuildCheckBox(LSs.WelcomeSoftwareRender, forceSoftwareRenderer, out forceSoftwareRenderer); if (forceSoftwareRenderer != Preferences.Instance.forceSoftwareRenderer) { Preferences.Instance.forceSoftwareRenderer = forceSoftwareRenderer; @@ -202,15 +243,15 @@ protected override void BuildContents(ImGui gui) { using (gui.EnterRow()) { if (Preferences.Instance.recentProjects.Length > 1) { - if (gui.BuildButton("Recent projects", SchemeColor.Grey)) { + if (gui.BuildButton(LSs.RecentProjects, SchemeColor.Grey)) { gui.ShowDropDown(BuildRecentProjectsDropdown, 35f); } } - if (gui.BuildButton(Icon.Help).WithTooltip(gui, "About YAFC")) { + if (gui.BuildButton(Icon.Help).WithTooltip(gui, LSs.AboutYafc)) { _ = new AboutScreen(this); } - if (gui.BuildButton(Icon.DarkMode).WithTooltip(gui, "Toggle dark mode")) { + if (gui.BuildButton(Icon.DarkMode).WithTooltip(gui, LSs.ToggleDarkMode)) { Preferences.Instance.darkMode = !Preferences.Instance.darkMode; RenderingUtils.SetColorScheme(Preferences.Instance.darkMode); Preferences.Instance.Save(); @@ -224,68 +265,124 @@ protected override void BuildContents(ImGui gui) { private void ProjectErrorMoreInfo(ImGui gui) { - gui.BuildWrappedText("Check that these mods load in Factorio."); - gui.BuildWrappedText("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 saves mod-list.json only when exiting."); - gui.BuildWrappedText("Check that Factorio loads mods from the same folder as YAFC."); - gui.BuildWrappedText("If that doesn't help, try removing the mods that have several versions, or are disabled, or don't have the required dependencies."); + gui.BuildWrappedText(LSs.LoadErrorAdvice); // The whole line is underlined if the allocator is not set to LeftAlign gui.allocator = RectAllocator.LeftAlign; - if (gui.BuildLink("If all else fails, then create an issue on GitHub")) { + if (gui.BuildLink(LSs.LoadErrorCreateIssue)) { Ui.VisitLink(AboutScreen.Github); } - gui.BuildWrappedText("Please attach a new-game save file to sync mods, versions, and settings."); + gui.BuildWrappedText(LSs.LoadErrorCreateIssueWithInformation); } - private static void DoLanguageList(ImGui gui, Dictionary list, bool enabled) { + private void DoLanguageList(ImGui gui, SortedList list, bool listFontSupported) { foreach (var (k, v) in list) { - if (!enabled) { - gui.BuildText(v); + if (!Font.text.CanDraw(v.NativeName + v.CheckExtraCharacters) && !listFontSupported) { + bool result; + if (Font.text.CanDraw(v.NativeName)) { + result = gui.BuildLink($"{v.NativeName} ({k})"); + } + else { + result = gui.BuildLink($"{v.LatinName} ({k})"); + } + + if (result) { + if (Font.FilesExist(v.BaseFontName) || Program.hasOverriddenFont) { + Preferences.Instance.language = k; + Preferences.Instance.Save(); + gui.CloseDropdown(); + restartIfNecessary(); + } + else { + gui.ShowDropDown(async gui => { + gui.BuildText(LSs.WelcomeAlertDownloadFont, TextBlockDisplayStyle.WrappedText); + gui.allocator = RectAllocator.Center; + if (gui.BuildButton(LSs.Confirm)) { + gui.CloseDropdown(); + downloading = true; + // Jump through several hoops to download an appropriate Noto Sans font. + // Google Webfonts Helper is the first place I found that would (eventually) let me directly download ttf files + // for the static fonts. It can also provide links to Google, but that would take three requests, instead of two. + // Variable fonts can be acquired from github, but SDL_ttf doesn't support those yet. + // See https://gwfh.mranftl.com/fonts, https://github.com/majodev/google-webfonts-helper, and + // https://github.com/libsdl-org/SDL_ttf/pull/506 + HttpClient client = new(); + // Get the character subsets supported by this font + string query = "https://gwfh.mranftl.com/api/fonts/" + v.BaseFontName; + dynamic response = JObject.Parse(await client.GetStringAsync(query)); + // Request a zip containing ttf files and all character subsets + query += "?variants=300,regular&download=zip&formats=ttf&subsets="; + foreach (string item in response["subsets"]) { + query += item + ","; + } + ZipArchive archive = new(await client.GetStreamAsync(query)); + // Extract the two ttf files into the expected locations + foreach (var entry in archive.Entries) { + if (entry.Name.Contains("300")) { + entry.ExtractToFile($"Data/{v.BaseFontName}-Light.ttf"); + } + else { + entry.ExtractToFile($"Data/{v.BaseFontName}-Regular.ttf"); + } + } + // Save and restart + Preferences.Instance.language = k; + Preferences.Instance.Save(); + restartIfNecessary(); + } + }); + } + } } - else if (gui.BuildLink(v)) { + else if (Font.text.CanDraw(v.NativeName + v.CheckExtraCharacters) && listFontSupported && gui.BuildLink(v.NativeName + " (" + k + ")")) { Preferences.Instance.language = k; Preferences.Instance.Save(); _ = gui.CloseDropdown(); } } + + static void restartIfNecessary() { + if (!Program.hasOverriddenFont) { + Process.Start("dotnet", Assembly.GetEntryAssembly()!.Location); + Environment.Exit(0); + } + } } private void LanguageSelection(ImGui gui) { gui.spacing = 0f; gui.allocator = RectAllocator.LeftAlign; - gui.BuildText("Mods may not support your language, using English as a fallback.", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.WelcomeAlertEnglishFallback, TextBlockDisplayStyle.WrappedText); gui.AllocateSpacing(0.5f); DoLanguageList(gui, languageMapping, true); if (!Program.hasOverriddenFont) { gui.AllocateSpacing(0.5f); - 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.BuildText(LSs.WelcomeAlertNeedADifferentFont, TextBlockDisplayStyle.WrappedText); gui.AllocateSpacing(0.5f); } - DoLanguageList(gui, languagesRequireFontOverride, Program.hasOverriddenFont); + DoLanguageList(gui, languageMapping, false); gui.AllocateSpacing(0.5f); - if (gui.BuildButton("Select font to override")) { + if (gui.BuildButton(LSs.WelcomeSelectFont)) { SelectFont(); } if (Preferences.Instance.overrideFont != null) { gui.BuildText(Preferences.Instance.overrideFont, TextBlockDisplayStyle.WrappedText); - if (gui.BuildLink("Reset font to default")) { + if (gui.BuildLink(LSs.WelcomeResetFont)) { Preferences.Instance.overrideFont = null; languageScroll.RebuildContents(); Preferences.Instance.Save(); } } - gui.BuildText("Selecting font to override require YAFC restart to take effect", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.WelcomeResetFontRestart, TextBlockDisplayStyle.WrappedText); } private async void SelectFont() { - string? result = await new FilesystemScreen("Override font", "Override font that YAFC uses", "Ok", null, FilesystemScreen.Mode.SelectFile, null, this, null, null); + string? result = await new FilesystemScreen(LSs.OverrideFont, LSs.OverrideFontLong, LSs.Ok, null, FilesystemScreen.Mode.SelectFile, null, this, null, null); if (result == null) { return; } @@ -310,19 +407,19 @@ private void ValidateSelection() { bool projectExists = File.Exists(path); if (projectExists) { - createText = "Load '" + Path.GetFileNameWithoutExtension(path) + "'"; + createText = LSs.LoadProjectName.L(Path.GetFileNameWithoutExtension(path)); } else if (path != "") { string? directory = Path.GetDirectoryName(path); if (!Directory.Exists(directory)) { - createText = "Project directory does not exist"; + createText = LSs.WelcomeAlertMissingDirectory; canCreate = false; return; } - createText = "Create '" + Path.GetFileNameWithoutExtension(path) + "'"; + createText = LSs.WelcomeCreateProjectName.L(Path.GetFileNameWithoutExtension(path)); } else { - createText = "Create new project"; + createText = LSs.WelcomeCreateUnnamedProject; } canCreate = factorioValid && modsValid; @@ -332,7 +429,7 @@ private void BuildPathSelect(ImGui gui, string path, string description, string gui.BuildText(description, TextBlockDisplayStyle.WrappedText); gui.spacing = 0.5f; using (gui.EnterGroup(default, RectAllocator.RightRow)) { - if (gui.BuildButton("...")) { + if (gui.BuildButton(LSs.WelcomeBrowseButton)) { ShowFileSelect(description, path, editType); } @@ -449,19 +546,19 @@ private async void ShowFileSelect(string description, string path, EditType type FilesystemScreen.Mode fsMode; if (type == EditType.Workspace) { - buttonText = "Select"; + buttonText = LSs.Select; location = Path.GetDirectoryName(path); fsMode = FilesystemScreen.Mode.SelectOrCreateFile; fileExtension = "yafc"; } else { - buttonText = "Select folder"; + buttonText = LSs.SelectFolder; location = path; fsMode = FilesystemScreen.Mode.SelectFolder; fileExtension = null; } - string? result = await new FilesystemScreen("Select folder", description, buttonText, location, fsMode, "", this, GetFolderFilter(type), fileExtension); + string? result = await new FilesystemScreen(LSs.SelectFolder, description, buttonText, location, fsMode, "", this, GetFolderFilter(type), fileExtension); if (result != null) { if (type == EditType.Factorio) { diff --git a/Yafc/Windows/WizardPanel.cs b/Yafc/Windows/WizardPanel.cs index 7c744144..6aa16a99 100644 --- a/Yafc/Windows/WizardPanel.cs +++ b/Yafc/Windows/WizardPanel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Yafc.I18n; using Yafc.UI; #nullable disable warnings // Disabling nullable for legacy code. @@ -33,7 +34,7 @@ public override void Build(ImGui gui) { bool valid = true; pages[page](gui, ref valid); using (gui.EnterRow(allocator: RectAllocator.RightRow)) { - if (gui.BuildButton(page >= pages.Count - 1 ? "Finish" : "Next", active: valid)) { + if (gui.BuildButton(page >= pages.Count - 1 ? LSs.WizardFinish : LSs.WizardNext, active: valid)) { if (page < pages.Count - 1) { page++; } @@ -42,11 +43,11 @@ public override void Build(ImGui gui) { finish(); } } - if (page > 0 && gui.BuildButton("Previous")) { + if (page > 0 && gui.BuildButton(LSs.WizardPrevious)) { page--; } - gui.BuildText("Step " + (page + 1) + " of " + pages.Count); + gui.BuildText(LSs.WizardStepXOfY.L((page + 1), pages.Count)); } } } diff --git a/Yafc/Workspace/AutoPlannerView.cs b/Yafc/Workspace/AutoPlannerView.cs index 64fd0f7c..a95f8d99 100644 --- a/Yafc/Workspace/AutoPlannerView.cs +++ b/Yafc/Workspace/AutoPlannerView.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -25,15 +26,15 @@ protected override void BuildPageTooltip(ImGui gui, AutoPlanner contents) { private Action CreateAutoPlannerWizard(List pages) { List goal = []; - string pageName = "Auto planner"; + string pageName = LSs.AutoPlanner; 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.", + gui.BuildText(LSs.AutoPlannerWarning, TextBlockDisplayStyle.ErrorText); - gui.BuildText("Enter page name:"); + gui.BuildText(LSs.AutoPlannerPageName); _ = gui.BuildTextInput(pageName, out pageName, null); gui.AllocateSpacing(2f); - gui.BuildText("Select your goal:"); + gui.BuildText(LSs.AutoPlannerGoal); using (var grid = gui.EnterInlineGrid(3f)) { for (int i = 0; i < goal.Count; i++) { var elem = goal[i]; @@ -50,7 +51,7 @@ void page1(ImGui gui, ref bool valid) { } grid.Next(); if (gui.BuildButton(Icon.Plus, SchemeColor.Primary, SchemeColor.PrimaryAlt, size: 2.5f)) { - SelectSingleObjectPanel.Select(Database.goods.explorable, "New production goal", x => { + SelectSingleObjectPanel.Select(Database.goods.explorable, LSs.AutoPlannerSelectProductionGoal, x => { goal.Add(new AutoPlannerGoal { amount = 1f, item = x }); gui.Rebuild(); }); @@ -58,7 +59,7 @@ void page1(ImGui gui, ref bool valid) { grid.Next(); } gui.AllocateSpacing(2f); - gui.BuildText("Review active milestones, as they will restrict recipes that are considered:", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.AutoPlannerReviewMilestones, TextBlockDisplayStyle.WrappedText); new MilestonesWidget().Build(gui); gui.AllocateSpacing(2f); valid = !string.IsNullOrEmpty(pageName) && goal.Count > 0; @@ -66,7 +67,7 @@ void page1(ImGui gui, ref bool valid) { pages.Add(page1); return () => { - var planner = MainScreen.Instance.AddProjectPage("Auto planner", goal[0].item, typeof(AutoPlanner), false, false); + var planner = MainScreen.Instance.AddProjectPage(LSs.AutoPlanner, goal[0].item, typeof(AutoPlanner), false, false); (planner.content as AutoPlanner).goals.AddRange(goal); MainScreen.Instance.SetActivePage(planner); }; diff --git a/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs b/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs index d45ee88d..bc50311d 100644 --- a/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs +++ b/Yafc/Workspace/ProductionSummary/ProductionSummaryView.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -9,7 +10,7 @@ namespace Yafc; public class ProductionSummaryView : ProjectPageView { private readonly DataGrid grid; private readonly FlatHierarchy flatHierarchy; - private ObjectWithQuality? filteredGoods; + private IObjectWithQuality? filteredGoods; private readonly Dictionary goodsToColumn = []; private readonly PaddingColumn padding; private readonly SummaryColumn firstColumn; @@ -20,7 +21,7 @@ public ProductionSummaryView() { firstColumn = new SummaryColumn(this); lastColumn = new RestGoodsColumn(this); grid = new DataGrid(padding, firstColumn, lastColumn) { headerHeight = 4.2f }; - flatHierarchy = new FlatHierarchy(grid, null, buildExpandedGroupRows: true); + flatHierarchy = new FlatHierarchy(grid, null, LSs.LegacySummaryEmptyGroup, buildExpandedGroupRows: true); } private class PaddingColumn(ProductionSummaryView view) : DataColumn(3f) { @@ -79,7 +80,7 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry entry) { BuildButtons(gui, 1.5f, entry.subgroup); } else { - if (gui.BuildTextInput(entry.subgroup.name, out string newText, "Group name", delayed: true)) { + if (gui.BuildTextInput(entry.subgroup.name, out string newText, LSs.LegacySummaryGroupNameHint, delayed: true)) { entry.subgroup.RecordUndo().name = newText; } } @@ -100,11 +101,11 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry entry) { } else if (buttonEvent == ButtonEvent.Click) { gui.ShowDropDown(dropdownGui => { - if (dropdownGui.BuildButton("Go to page") && dropdownGui.CloseDropdown()) { + if (dropdownGui.BuildButton(LSs.LegacySummaryGoToPage) && dropdownGui.CloseDropdown()) { MainScreen.Instance.SetActivePage(entry.page.page); } - if (dropdownGui.BuildRedButton("Remove") && dropdownGui.CloseDropdown()) { + if (dropdownGui.BuildRedButton(LSs.Remove) && dropdownGui.CloseDropdown()) { _ = entry.owner.RecordUndo().elements.Remove(entry); } }); @@ -113,7 +114,7 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry entry) { using (gui.EnterFixedPositioning(3f, 2f, default)) { gui.allocator = RectAllocator.LeftRow; - gui.BuildText("x"); + gui.BuildText(LSs.LegacySummaryMultiplierEditBoxPrefix); DisplayAmount amount = entry.multiplier; if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.FactorioObjectInput with { ColorGroup = SchemeColorGroup.Grey, Alignment = RectAlignment.MiddleLeft }) && amount.Value >= 0) { @@ -148,7 +149,7 @@ protected override void ModelContentsChanged(bool visualOnly) { private class GoodsColumn(ProductionSummaryColumn column, ProductionSummaryView view) : DataColumn(4f) { public readonly ProductionSummaryColumn column = column; - public ObjectWithQuality goods { get; } = column.goods as ObjectWithQuality ?? new(column.goods.target, column.goods.quality); + public IObjectWithQuality goods { get; } = column.goods as IObjectWithQuality ?? column.goods.target.With(column.goods.quality); public override void BuildHeader(ImGui gui) { var moveHandle = gui.statePosition; @@ -178,7 +179,7 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry data) { } } - private class RestGoodsColumn(ProductionSummaryView view) : TextDataColumn("Other", 30f, 5f, 40f) { + private class RestGoodsColumn(ProductionSummaryView view) : TextDataColumn(LSs.LegacySummaryOtherColumn, 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) { @@ -200,7 +201,7 @@ public override void BuildElement(ImGui gui, ProductionSummaryEntry data) { } } - private void ApplyFilter(ObjectWithQuality goods) { + private void ApplyFilter(IObjectWithQuality goods) { var filter = filteredGoods == goods ? null : goods; filteredGoods = filter; model.group.UpdateFilter(goods, default); @@ -266,7 +267,7 @@ private void AddOrRemoveColumn(IObjectWithQuality goods) { } if (!found) { - model.columns.Add(new ProductionSummaryColumn(model, new(goods.target, goods.quality))); + model.columns.Add(new ProductionSummaryColumn(model, goods)); } } @@ -281,10 +282,10 @@ protected override void BuildContent(ImGui gui) { gui.AllocateSpacing(1f); using (gui.EnterGroup(new Padding(1))) { if (model.group.elements.Count == 0) { - gui.BuildText("Add your existing sheets here to keep track of what you have in your base and to see what shortages you may have"); + gui.BuildText(LSs.LegacySummaryEmptyGroupDescription); } else { - gui.BuildText("List of goods produced/consumed by added blocks. Click on any of these to add it to (or remove it from) the table."); + gui.BuildText(LSs.LegacySummaryGroupDescription); } using var inlineGrid = gui.EnterInlineGrid(3f, 1f); diff --git a/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs b/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs index 72afb608..9d93218d 100644 --- a/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs +++ b/Yafc/Workspace/ProductionTable/ModuleCustomizationScreen.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Linq; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -37,22 +39,22 @@ public static void Show(ProjectModuleTemplate template) { } public override void Build(ImGui gui) { - BuildHeader(gui, "Module customization"); + BuildHeader(gui, LSs.ModuleCustomization); if (template != null) { using (gui.EnterRow()) { if (gui.BuildFactorioObjectButton(template.icon, ButtonDisplayStyle.Default) == Click.Left) { - SelectSingleObjectPanel.SelectWithNone(Database.objects.all, "Select icon", x => { + SelectSingleObjectPanel.SelectWithNone(Database.objects.all, LSs.SelectIcon, x => { template.RecordUndo().icon = x; Rebuild(); }); } - if (gui.BuildTextInput(template.name, out string newName, "Enter name", delayed: true) && newName != "") { + if (gui.BuildTextInput(template.name, out string newName, LSs.ModuleCustomizationNameHint, delayed: true) && newName != "") { template.RecordUndo().name = newName; } } - gui.BuildText("Filter by crafting buildings (Optional):"); + gui.BuildText(LSs.ModuleCustomizationFilterBuildings); using var grid = gui.EnterInlineGrid(2f, 1f); for (int i = 0; i < template.filterEntities.Count; i++) { @@ -77,32 +79,32 @@ void doToSelectedItem(EntityCrafter selectedCrafter) { } SelectSingleObjectPanel.Select(Database.allCrafters.Where(isSuitable), - "Add module template filter", + LSs.ModuleCustomizationAddFilterBuilding, doToSelectedItem); } } if (modules == null) { - if (gui.BuildButton("Enable custom modules")) { + if (gui.BuildButton(LSs.ModuleCustomizationEnable)) { modules = new ModuleTemplateBuilder(); } } else { ModuleEffects effects = new ModuleEffects(); if (recipe == null || recipe.entity?.target.moduleSlots > 0) { - gui.BuildText("Internal modules:", Font.subheader); - gui.BuildText("Leave zero amount to fill the remaining slots"); + gui.BuildText(LSs.ModuleCustomizationInternalModules, Font.subheader); + gui.BuildText(LSs.ModuleCustomizationLeaveZeroHint); DrawRecipeModules(gui, null, ref effects); } else { - gui.BuildText("This building doesn't have module slots, but can be affected by beacons"); + gui.BuildText(LSs.ModuleCustomizationBeaconsOnly); } - gui.BuildText("Beacon modules:", Font.subheader); + gui.BuildText(LSs.ModuleCustomizationBeaconModules, Font.subheader); if (modules.beacon == null) { - gui.BuildText("Use default parameters"); - if (gui.BuildButton("Override beacons as well")) { + gui.BuildText(LSs.ModuleCustomizationUsingDefaultBeacons); + if (gui.BuildButton(LSs.ModuleCustomizationOverrideBeacons)) { SelectBeacon(gui); } @@ -117,7 +119,7 @@ void doToSelectedItem(EntityCrafter selectedCrafter) { SelectBeacon(gui); } - string modulesNotBeacons = "Input the amount of modules, not the amount of beacons. Single beacon can hold " + modules.beacon.target.moduleSlots + " modules."; + string modulesNotBeacons = LSs.ModuleCustomizationUseNumberOfModulesInBeacons.L(modules.beacon.target.moduleSlots); gui.BuildText(modulesNotBeacons, TextBlockDisplayStyle.WrappedText); DrawRecipeModules(gui, modules.beacon, ref effects); } @@ -128,27 +130,38 @@ void doToSelectedItem(EntityCrafter selectedCrafter) { effects.consumption += baseEffect.consumption; } + if (recipe?.recipe.target is Recipe actualRecipe) { + Dictionary levels = Project.current.settings.productivityTechnologyLevels; + foreach ((Technology productivityTechnology, float changePerLevel) in actualRecipe.technologyProductivity) { + if (levels.TryGetValue(productivityTechnology, out int productivityTechLevel)) { + effects.productivity += changePerLevel * productivityTechLevel; + } + } + + effects.productivity = Math.Min(effects.productivity, actualRecipe.maximumProductivity ?? float.MaxValue); + } + if (recipe != null) { float craftingSpeed = (recipe.entity?.GetCraftingSpeed() ?? 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("Quality bonus: " + DataUtils.FormatAmount(effects.qualityMod, UnitOfMeasure.Percent) + " (multiplied by quality upgrade chance)"); + gui.BuildText(LSs.ModuleCustomizationCurrentEffects, Font.subheader); + gui.BuildText(LSs.ModuleCustomizationProductivityBonus.L(DataUtils.FormatAmount(effects.productivity, UnitOfMeasure.Percent))); + gui.BuildText(LSs.ModuleCustomizationSpeedBonus.L(DataUtils.FormatAmount(effects.speedMod - 1, UnitOfMeasure.Percent), DataUtils.FormatAmount(craftingSpeed, UnitOfMeasure.None))); + gui.BuildText(LSs.ModuleCustomizationQualityBonus.L(DataUtils.FormatAmount(effects.qualityMod, UnitOfMeasure.Percent))); - string energyUsageLine = "Energy usage: " + DataUtils.FormatAmount(effects.energyUsageMod, UnitOfMeasure.Percent); + string energyUsageLine = LSs.ModuleCustomizationEnergyUsage.L(DataUtils.FormatAmount(effects.energyUsageMod, UnitOfMeasure.Percent)); if (recipe.entity != null) { float power = effects.energyUsageMod * recipe.entity.GetPower() / recipe.entity.target.energy.effectivity; if (!recipe.recipe.target.flags.HasFlagAny(RecipeFlags.UsesFluidTemperature | RecipeFlags.ScaleProductionWithPower) && recipe.entity != null) { - energyUsageLine += " (" + DataUtils.FormatAmount(power, UnitOfMeasure.Megawatt) + " per building)"; + energyUsageLine = LSs.ModuleCustomizationEnergyUsagePerBuilding.L(DataUtils.FormatAmount(effects.energyUsageMod, UnitOfMeasure.Percent), + DataUtils.FormatAmount(power, UnitOfMeasure.Megawatt)); } gui.BuildText(energyUsageLine); float pps = craftingSpeed * (1f + MathF.Max(0f, effects.productivity)) / recipe.recipe.target.time; - gui.BuildText("Overall crafting speed (including productivity): " + DataUtils.FormatAmount(pps, UnitOfMeasure.PerSecond)); - gui.BuildText("Energy cost per recipe output: " + DataUtils.FormatAmount(power / pps, UnitOfMeasure.Megajoule)); + gui.BuildText(LSs.ModuleCustomizationOverallSpeed.L(DataUtils.FormatAmount(pps, UnitOfMeasure.PerSecond))); + gui.BuildText(LSs.ModuleCustomizationEnergyCostPerOutput.L(DataUtils.FormatAmount(power / pps, UnitOfMeasure.Megajoule))); } else { gui.BuildText(energyUsageLine); @@ -158,18 +171,18 @@ void doToSelectedItem(EntityCrafter selectedCrafter) { gui.AllocateSpacing(3f); using (gui.EnterRow(allocator: RectAllocator.RightRow)) { - if (template == null && gui.BuildButton("Cancel")) { + if (template == null && gui.BuildButton(LSs.Cancel)) { Close(); } - if (template != null && gui.BuildButton("Cancel (partial)")) { + if (template != null && gui.BuildButton(LSs.PartialCancel)) { Close(); } - if (gui.BuildButton("Done")) { + if (gui.BuildButton(LSs.Done)) { CloseWithResult(modules); } gui.allocator = RectAllocator.LeftRow; - if (modules != null && recipe != null && gui.BuildRedButton("Remove module customization")) { + if (modules != null && recipe != null && gui.BuildRedButton(LSs.ModuleCustomizationRemove)) { CloseWithResult(null); } } @@ -180,20 +193,20 @@ private void SelectBeacon(ImGui gui) { gui.BuildObjectQualitySelectDropDown(Database.allBeacons, sel => { modules.beacon = sel; contents.Rebuild(); - }, new("Select beacon"), Quality.Normal); + }, new(LSs.SelectBeacon), Quality.Normal); } else { gui.BuildObjectQualitySelectDropDownWithNone(Database.allBeacons, sel => { modules.beacon = sel; contents.Rebuild(); - }, new("Select beacon"), modules.beacon.quality, quality => { + }, new(LSs.SelectBeacon), modules.beacon.quality, quality => { modules.beacon = modules.beacon.With(quality); contents.Rebuild(); }); } } - private Module[] GetModules(ObjectWithQuality? beacon) { + private Module[] GetModules(IObjectWithQuality? beacon) { var modules = (beacon == null && recipe is { recipe: IObjectWithQuality rec }) ? [.. Database.allModules.Where(rec.CanAcceptModule)] : Database.allModules; @@ -205,13 +218,13 @@ private Module[] GetModules(ObjectWithQuality? beacon) { return [.. modules.Where(x => filter.CanAcceptModule(x.moduleSpecification))]; } - private void DrawRecipeModules(ImGui gui, ObjectWithQuality? beacon, ref ModuleEffects effects) { + private void DrawRecipeModules(ImGui gui, IObjectWithQuality? beacon, ref ModuleEffects effects) { int remainingModules = recipe?.entity?.target.moduleSlots ?? 0; using var grid = gui.EnterInlineGrid(3f, 1f); var list = beacon != null ? modules!.beaconList : modules!.list;// null-forgiving: Both calls are from places where we know modules is not null for (int i = 0; i < list.Count; i++) { grid.Next(); - (ObjectWithQuality module, int fixedCount) = list[i]; + (IObjectWithQuality module, int fixedCount) = list[i]; DisplayAmount amount = fixedCount; switch (gui.BuildFactorioObjectWithEditableAmount(module, amount, ButtonDisplayStyle.ProductionTableUnscaled)) { case GoodsWithAmountEvent.LeftButtonClick: @@ -225,8 +238,8 @@ private void DrawRecipeModules(ImGui gui, ObjectWithQuality? beaco list[idx] = (sel, list[idx].fixedCount); } gui.Rebuild(); - }, new("Select module", DataUtils.FavoriteModule), list[idx].module.quality, quality => { - list[idx] = list[idx] with { module = new(list[idx].module.target, quality) }; + }, new(LSs.SelectModule, DataUtils.FavoriteModule), list[idx].module.quality, quality => { + list[idx] = list[idx] with { module = list[idx].module.target.With(quality) }; gui.Rebuild(); }); break; @@ -254,7 +267,7 @@ private void DrawRecipeModules(ImGui gui, ObjectWithQuality? beaco gui.BuildObjectQualitySelectDropDown(GetModules(beacon), sel => { list.Add(new(sel, 0)); gui.Rebuild(); - }, new("Select module", DataUtils.FavoriteModule), Quality.Normal); + }, new(LSs.SelectModule, DataUtils.FavoriteModule), Quality.Normal); } } diff --git a/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs b/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs index 3674b785..328101c7 100644 --- a/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs +++ b/Yafc/Workspace/ProductionTable/ModuleFillerParametersScreen.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -31,11 +32,11 @@ private void ListDrawer(ImGui gui, KeyValuePair { using (gui.EnterRow()) { // Allocate the width now, but draw the text later so it can be vertically centered. - Rect rect = gui.AllocateTextRect(out _, "Affected by " + element.Value.beaconCount, TextBlockDisplayStyle.Default(SchemeColor.None)); + Rect rect = gui.AllocateTextRect(out _, LSs.AffectedByMBeacons.L(element.Value.beaconCount), TextBlockDisplayStyle.Default(SchemeColor.None)); gui.BuildFactorioObjectIcon(element.Value.beacon, ButtonDisplayStyle.ProductionTableUnscaled); rect.Height = gui.lastRect.Height; - gui.DrawText(rect, "Affected by " + element.Value.beaconCount); - gui.BuildText("each containing " + element.Value.beacon.target.moduleSlots); + gui.DrawText(rect, LSs.AffectedByMBeacons.L(element.Value.beaconCount)); + gui.BuildText(LSs.EachContainingNModules.L(element.Value.beacon.target.moduleSlots)); gui.BuildFactorioObjectIcon(element.Value.beaconModule, ButtonDisplayStyle.ProductionTableUnscaled); } } @@ -44,7 +45,7 @@ private void ListDrawer(ImGui gui, KeyValuePair { + SelectSingleObjectPanel.SelectQualityWithNone(Database.usableBeacons, LSs.SelectBeacon, selectedBeacon => { if (selectedBeacon is null) { _ = modules.overrideCrafterBeacons.Remove(crafter); @@ -55,16 +56,16 @@ private void ListDrawer(ImGui gui, KeyValuePair modules.overrideCrafterBeacons[crafter].beacon.target.CanAcceptModule(m.moduleSpecification)), - "Select beacon module", selectedModule => { + LSs.SelectBeaconModule, selectedModule => { if (selectedModule is null) { _ = modules.overrideCrafterBeacons.Remove(crafter); @@ -74,7 +75,7 @@ private void ListDrawer(ImGui gui, KeyValuePair= 0: modules.overrideCrafterBeacons[crafter] = modules.overrideCrafterBeacons[crafter] with { beaconCount = (int)amount.Value }; @@ -95,13 +96,13 @@ public static void BuildSimple(ImGui gui, ModuleFillerParameters modules) { } if (payback <= 0f) { - gui.BuildText("Use no modules"); + gui.BuildText(LSs.UseNoModules); } else if (payback >= float.MaxValue) { - gui.BuildText("Use best modules"); + gui.BuildText(LSs.UseBestModules); } else { - gui.BuildText("Modules payback estimate: " + DataUtils.FormatTime(payback), TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ModuleFillerPaybackEstimate.L(DataUtils.FormatTime(payback)), TextBlockDisplayStyle.WrappedText); } } @@ -111,30 +112,30 @@ public static void BuildSimple(ImGui gui, ModuleFillerParameters modules) { public override void Build(ImGui gui) { EntityBeacon? beacon = Database.usableBeacons.FirstOrDefault(); _ = Database.GetDefaultModuleFor(beacon, out Module? defaultBeaconModule); - ObjectWithQuality? defaultBeacon = beacon == null ? null : new(beacon, Quality.MaxAccessible); - ObjectWithQuality? beaconFillerModule = (defaultBeaconModule, Quality.MaxAccessible); + IObjectWithQuality? defaultBeacon = beacon.With(Quality.MaxAccessible); + IObjectWithQuality? beaconFillerModule = defaultBeaconModule.With(Quality.MaxAccessible); - BuildHeader(gui, "Module autofill parameters"); + BuildHeader(gui, LSs.ModuleFillerHeaderAutofill); BuildSimple(gui, modules); - if (gui.BuildCheckBox("Fill modules in miners", modules.fillMiners, out bool newFill)) { + if (gui.BuildCheckBox(LSs.ModuleFillerFillMiners, modules.fillMiners, out bool newFill)) { modules.fillMiners = newFill; } gui.AllocateSpacing(); - gui.BuildText("Filler module:", Font.subheader); - gui.BuildText("Use this module when autofill doesn't add anything (for example when productivity modules doesn't fit)", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ModuleFillerModule, Font.subheader); + gui.BuildText(LSs.ModuleFillerModuleHint, TextBlockDisplayStyle.WrappedText); if (gui.BuildFactorioObjectButtonWithText(modules.fillerModule) == Click.Left) { - SelectSingleObjectPanel.SelectQualityWithNone(Database.allModules, "Select filler module", select => modules.fillerModule = select, modules.fillerModule?.quality); + SelectSingleObjectPanel.SelectQualityWithNone(Database.allModules, LSs.ModuleFillerSelectModule, select => modules.fillerModule = select, modules.fillerModule?.quality); } gui.AllocateSpacing(); - gui.BuildText("Beacons & beacon modules:", Font.subheader); + gui.BuildText(LSs.ModuleFillerHeaderBeacons, Font.subheader); if (defaultBeacon is null || beaconFillerModule is null) { - gui.BuildText("Your mods contain no beacons, or no modules that can be put into beacons."); + gui.BuildText(LSs.ModuleFillerNoBeacons); } else { if (gui.BuildFactorioObjectButtonWithText(modules.beacon) == Click.Left) { - SelectSingleObjectPanel.SelectQualityWithNone(Database.allBeacons, "Select beacon", select => { + SelectSingleObjectPanel.SelectQualityWithNone(Database.allBeacons, LSs.SelectBeacon, select => { modules.beacon = select; if (modules.beaconModule != null && (modules.beacon == null || !modules.beacon.target.CanAcceptModule(modules.beaconModule))) { modules.beaconModule = null; @@ -146,35 +147,34 @@ public override void Build(ImGui gui) { if (gui.BuildFactorioObjectButtonWithText(modules.beaconModule) == Click.Left) { SelectSingleObjectPanel.SelectQualityWithNone(Database.allModules.Where(x => modules.beacon?.target.CanAcceptModule(x.moduleSpecification) ?? false), - "Select module for beacon", select => modules.beaconModule = select, modules.beaconModule?.quality); + LSs.ModuleFillerSelectBeaconModule, select => modules.beaconModule = select, modules.beaconModule?.quality); } using (gui.EnterRow()) { - gui.BuildText("Beacons per building: "); + gui.BuildText(LSs.ModuleFillerBeaconsPerBuilding); DisplayAmount amount = modules.beaconsPerBuilding; if (gui.BuildFloatInput(amount, TextBoxDisplayStyle.ModuleParametersTextInput) && (int)amount.Value > 0) { modules.beaconsPerBuilding = (int)amount.Value; } } - gui.BuildText("Please note that beacons themselves are not part of the calculation", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ModuleFillerBeaconsNotCalculated, TextBlockDisplayStyle.WrappedText); gui.AllocateSpacing(); - gui.BuildText("Override beacons:", Font.subheader); + gui.BuildText(LSs.ModuleFillerOverrideBeacons, 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); - gui.BuildText("Select the 'none' item in either prompt to remove the override.", topOffset: -0.5f); + gui.BuildText(LSs.ModuleFillerOverrideBeaconsHint, TextBlockDisplayStyle.WrappedText, topOffset: -0.5f); } } gui.AllocateSpacing(.5f); overrideList.Build(gui); using (gui.EnterRow(allocator: RectAllocator.Center)) { - if (gui.BuildButton("Add an override for a building type")) { + if (gui.BuildButton(LSs.ModuleFillerAddBeaconOverride)) { SelectMultiObjectPanel.Select(Database.allCrafters.Where(x => x.allowedEffects != AllowedEffects.None && !modules.overrideCrafterBeacons.ContainsKey(x)), - "Add exception(s) for:", + LSs.ModuleFillerSelectOverriddenCrafter, crafter => { modules.overrideCrafterBeacons[crafter] = new BeaconOverrideConfiguration(modules.beacon ?? defaultBeacon, modules.beaconsPerBuilding, modules.beaconModule ?? beaconFillerModule); @@ -184,7 +184,7 @@ public override void Build(ImGui gui) { } } - if (gui.BuildButton("Done")) { + if (gui.BuildButton(LSs.Done)) { Close(); } diff --git a/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs b/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs index 5af883e9..b1b0ba00 100644 --- a/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs +++ b/Yafc/Workspace/ProductionTable/ModuleTemplateConfiguration.cs @@ -1,4 +1,5 @@ using System.Numerics; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -55,7 +56,7 @@ public override void Activated() { } public override void Build(ImGui gui) { - BuildHeader(gui, "Module templates"); + BuildHeader(gui, LSs.ModuleTemplates); templateList.Build(gui); if (pageToDelete != null) { _ = Project.current.RecordUndo().sharedModuleTemplates.Remove(pageToDelete); @@ -63,7 +64,7 @@ public override void Build(ImGui gui) { pageToDelete = null; } using (gui.EnterRow(0.5f, RectAllocator.RightRow)) { - if (gui.BuildButton("Create", active: newPageName != "")) { + if (gui.BuildButton(LSs.Create, active: newPageName != "")) { ProjectModuleTemplate template = new(Project.current, newPageName); Project.current.RecordUndo().sharedModuleTemplates.Add(template); newPageName = ""; @@ -71,7 +72,7 @@ public override void Build(ImGui gui) { RefreshList(); } - _ = gui.RemainingRow().BuildTextInput(newPageName, out newPageName, "Create new template", setKeyboardFocus: SetKeyboardFocus.OnFirstPanelDraw); + _ = gui.RemainingRow().BuildTextInput(newPageName, out newPageName, LSs.CreateNewTemplateHint, setKeyboardFocus: SetKeyboardFocus.OnFirstPanelDraw); } } } diff --git a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs index d0304bd6..0ca8007d 100644 --- a/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs +++ b/Yafc/Workspace/ProductionTable/ProductionLinkSummaryScreen.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using SDL2; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -24,27 +25,27 @@ private ProductionLinkSummaryScreen(ProductionLink link) { } private void BuildScrollArea(ImGui gui) { - gui.BuildText("Production: " + DataUtils.FormatAmount(totalInput, link.flowUnitOfMeasure), Font.subheader); + gui.BuildText(LSs.LinkSummaryProduction.L(DataUtils.FormatAmount(totalInput, link.flowUnitOfMeasure)), Font.subheader); BuildFlow(gui, input, totalInput, false); if (link.capturedRecipes.Any(r => r is not RecipeRow)) { // captured recipes that are not user-visible RecipeRows imply the existence of implicit links using (gui.EnterRow()) { gui.AllocateRect(0, ButtonDisplayStyle.Default.Size); - gui.BuildText("Plus additional production from implicit links"); + gui.BuildText(LSs.LinkSummaryImplicitLinks); } } gui.spacing = 0.5f; - gui.BuildText("Consumption: " + DataUtils.FormatAmount(totalOutput, link.flowUnitOfMeasure), Font.subheader); + gui.BuildText(LSs.LinkSummaryConsumption.L(DataUtils.FormatAmount(totalOutput, link.flowUnitOfMeasure)), Font.subheader); BuildFlow(gui, output, totalOutput, true); if (link.amount != 0) { gui.spacing = 0.5f; - gui.BuildText((link.amount > 0 ? "Requested production: " : "Requested consumption: ") + DataUtils.FormatAmount(MathF.Abs(link.amount), - link.flowUnitOfMeasure), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.GreenAlt)); + gui.BuildText((link.amount > 0 ? LSs.LinkSummaryRequestedProduction : LSs.LinkSummaryRequestedConsumption).L(DataUtils.FormatAmount(MathF.Abs(link.amount), + link.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.flowUnitOfMeasure), + gui.BuildText((amount > 0 ? LSs.LinkSummaryOverproduction : LSs.LinkSummaryOverconsumption).L(DataUtils.FormatAmount(MathF.Abs(amount), link.flowUnitOfMeasure)), new TextBlockDisplayStyle(Font.subheader, Color: SchemeColor.Error)); } ShowRelatedLinks(gui); @@ -81,25 +82,25 @@ private void ShowRelatedLinks(ImGui gui) { var color = 1; gui.spacing = 0.75f; if (childLinks.Values.Any(e => e.Any())) { - gui.BuildText("Child links: ", Font.productionTableHeader); + gui.BuildText(LSs.LinkSummaryChildLinks, Font.productionTableHeader); foreach (var relTable in childLinks.Values) { BuildFlow(gui, relTable, relTable.Sum(e => Math.Abs(e.flow)), false, color++); } } if (parentLinks.Values.Any(e => e.Any())) { - gui.BuildText("Parent links: ", Font.productionTableHeader); + gui.BuildText(LSs.LinkSummaryParentLinks, Font.productionTableHeader); foreach (var relTable in parentLinks.Values) { BuildFlow(gui, relTable, relTable.Sum(e => Math.Abs(e.flow)), false, color++); } } if (otherLinks.Values.Any(e => e.Any())) { - gui.BuildText("Unrelated links: ", Font.productionTableHeader); + gui.BuildText(LSs.LinkSummaryUnrelatedLinks, Font.productionTableHeader); foreach (var relTable in otherLinks.Values) { BuildFlow(gui, relTable, relTable.Sum(e => Math.Abs(e.flow)), false, color++); } } if (unlinked.Any()) { - gui.BuildText("Unlinked: ", Font.subheader); + gui.BuildText(LSs.LinkSummaryUnlinked, Font.subheader); BuildFlow(gui, unlinked, 0, false); } } @@ -129,17 +130,17 @@ private bool IsLinkParent(RecipeRow row, List parents) => row.Ingre .Any(e => parents.Contains(e.owner)); public override void Build(ImGui gui) { - BuildHeader(gui, "Link summary"); + BuildHeader(gui, LSs.LinkSummary); using (gui.EnterRow()) { - gui.BuildText("Exploring link for: ", topOffset: 0.5f); - _ = gui.BuildFactorioObjectButtonWithText(link.goods, tooltipOptions: DrawParentRecipes(link.owner, "link")); + gui.BuildText(LSs.LinkSummaryHeader, topOffset: 0.5f); + _ = gui.BuildFactorioObjectButtonWithText(link.goods, tooltipOptions: DrawParentRecipes(link.owner, LSs.LinkSummaryLinkNestedUnder)); } scrollArea.Build(gui); - if (gui.BuildButton("Remove link", link.owner.allLinks.Contains(link) ? SchemeColor.Primary : SchemeColor.Grey)) { + if (gui.BuildButton(LSs.RemoveLink, link.owner.allLinks.Contains(link) ? SchemeColor.Primary : SchemeColor.Grey)) { DestroyLink(link); Close(); } - if (gui.BuildButton("Done")) { + if (gui.BuildButton(LSs.Done)) { Close(); } } @@ -155,10 +156,10 @@ private void BuildFlow(ImGui gui, List<(RecipeRow row, float flow)> list, float gui.spacing = 0f; foreach (var (row, flow) in list) { string amount = DataUtils.FormatAmount(flow, link.flowUnitOfMeasure); - if (gui.BuildFactorioObjectButtonWithText(row.recipe, amount, tooltipOptions: DrawParentRecipes(row.owner, "recipe")) == Click.Left) { + if (gui.BuildFactorioObjectButtonWithText(row.recipe, amount, tooltipOptions: DrawParentRecipes(row.owner, LSs.LinkSummaryRecipeNestedUnder)) == Click.Left) { // Find the corresponding links associated with the clicked recipe, IEnumerable> goods = (isLinkOutput ? row.Products.Select(p => p.Goods) : row.Ingredients.Select(i => i.Goods))!; - Dictionary, ProductionLink> links = goods + Dictionary, ProductionLink> links = goods .Select(goods => { row.FindLink(goods, out IProductionLink? link); return link as ProductionLink; }) .WhereNotNull() .ToDictionary(x => x.goods); @@ -177,18 +178,18 @@ private void BuildFlow(ImGui gui, List<(RecipeRow row, float flow)> list, float void drawLinks(ImGui gui) { if (links.Count == 0) { - string text = isLinkOutput ? "This recipe has no linked products." : "This recipe has no linked ingredients"; + string text = isLinkOutput ? LSs.LinkSummaryNoProducts : LSs.LinkSummaryNoIngredients; gui.BuildText(text, TextBlockDisplayStyle.ErrorText); } else { IComparer> comparer = DataUtils.DefaultOrdering; - if (isLinkOutput && row.recipe.mainProduct().Is(out var mainProduct)) { + if (isLinkOutput && row.recipe.mainProduct() is IObjectWithQuality mainProduct) { comparer = DataUtils.CustomFirstItemComparer(mainProduct, comparer); } - string header = isLinkOutput ? "Select product link to inspect" : "Select ingredient link to inspect"; - ObjectSelectOptions> options = new(header, comparer, int.MaxValue); - if (gui.BuildInlineObjectList(links.Keys, out ObjectWithQuality? selected, options) && gui.CloseDropdown()) { + string header = isLinkOutput ? LSs.LinkSummarySelectProduct : LSs.LinkSummarySelectIngredient; + ObjectSelectOptions> options = new(header, comparer, int.MaxValue); + if (gui.BuildInlineObjectList(links.Keys, out IObjectWithQuality? selected, options) && gui.CloseDropdown()) { changeLinkView(links[selected]); } } @@ -228,7 +229,7 @@ private static SchemeColor GetFlowColor(int colorIndex) { /// The first table to consider when determining what recipe rows to report. /// "link" or "recipe", for the text "This X is nested under:" /// A that will draw the appropriate information when called. - private static DrawBelowHeader DrawParentRecipes(ProductionTable table, string type) => gui => { + private static DrawBelowHeader DrawParentRecipes(ProductionTable table, LocalizableString0 type) => gui => { // Collect the parent recipes (equivalently, the table headers of all tables that contain table) Stack parents = new(); while (table?.owner is RecipeRow row) { @@ -237,7 +238,7 @@ private static DrawBelowHeader DrawParentRecipes(ProductionTable table, string t } if (parents.Count > 0) { - gui.BuildText($"This {type} is nested under:", TextBlockDisplayStyle.WrappedText); + gui.BuildText(type, TextBlockDisplayStyle.WrappedText); // Draw the parents with nesting float padding = 0.5f; diff --git a/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs b/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs index 1eb1dc26..029467d3 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableFlatHierarchy.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -17,7 +18,7 @@ namespace Yafc; /// GUI system. /// /// -public class FlatHierarchy(DataGrid grid, Action? drawTableHeader, string emptyGroupMessage = "This is an empty group", bool buildExpandedGroupRows = true) +public class FlatHierarchy(DataGrid grid, Action? drawTableHeader, LocalizableString0 emptyGroupMessage, bool buildExpandedGroupRows = true) where TRow : ModelObject, IGroupedElement where TGroup : ModelObject, IElementGroup { // These two arrays contain: diff --git a/Yafc/Workspace/ProductionTable/ProductionTableView.cs b/Yafc/Workspace/ProductionTable/ProductionTableView.cs index db1bf9ee..64785b67 100644 --- a/Yafc/Workspace/ProductionTable/ProductionTableView.cs +++ b/Yafc/Workspace/ProductionTable/ProductionTableView.cs @@ -4,9 +4,9 @@ using System.Numerics; using SDL2; using Yafc.Blueprints; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; -using static Yafc.UI.ImGui; namespace Yafc; @@ -18,7 +18,7 @@ public ProductionTableView() { 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."); + LSs.ProductionTableNestedGroup); } /// If not , names an instance property in that will be used to store the width of this column. @@ -68,9 +68,9 @@ public override void BuildElement(ImGui gui, RecipeRow row) { g.boxColor = SchemeColor.Error; g.textColor = SchemeColor.ErrorText; } - foreach (var (flag, text) in WarningsMeaning) { + foreach (var (flag, key) in WarningsMeaning) { if ((row.warningFlags & flag) != 0) { - g.BuildText(text, TextBlockDisplayStyle.WrappedText); + g.BuildText(key, TextBlockDisplayStyle.WrappedText); } } }); @@ -82,6 +82,9 @@ public override void BuildElement(ImGui gui, RecipeRow row) { else if (row.warningFlags.HasFlag(WarningFlags.UselessQuality)) { _ = MainScreen.Instance.ShowPseudoScreen(new MilestonesPanel()); } + else if (row.warningFlags.HasFlag(WarningFlags.ExcessProductivity)) { + PreferencesScreen.ShowProgression(); + } } } else { @@ -114,7 +117,7 @@ private static void BuildRowMarker(ImGui gui, RecipeRow row) { } } - private class RecipeColumn(ProductionTableView view) : ProductionTableDataColumn(view, "Recipe", 13f, 13f, 30f, widthStorage: nameof(Preferences.recipeColumnWidth)) { + private class RecipeColumn(ProductionTableView view) : ProductionTableDataColumn(view, LSs.ProductionTableHeaderRecipe, 13f, 13f, 30f, widthStorage: nameof(Preferences.recipeColumnWidth)) { public override void BuildElement(ImGui gui, RecipeRow recipe) { gui.spacing = 0.5f; switch (gui.BuildFactorioObjectButton(recipe.recipe, ButtonDisplayStyle.ProductionTableUnscaled)) { @@ -122,11 +125,11 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { gui.ShowDropDown(delegate (ImGui imgui) { DrawRecipeTagSelect(imgui, recipe); - if (recipe.subgroup == null && imgui.BuildButton("Create nested table") && imgui.CloseDropdown()) { + if (recipe.subgroup == null && imgui.BuildButton(LSs.ProductionTableCreateNested) && imgui.CloseDropdown()) { recipe.RecordUndo().subgroup = new ProductionTable(recipe); } - if (recipe.subgroup != null && imgui.BuildButton("Add nested desired product") && imgui.CloseDropdown()) { + if (recipe.subgroup != null && imgui.BuildButton(LSs.ProductionTableAddNestedProduct) && imgui.CloseDropdown()) { AddDesiredProductAtLevel(recipe.subgroup); } @@ -134,29 +137,29 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { BuildRecipeButton(imgui, recipe.subgroup); } - if (recipe.subgroup != null && imgui.BuildButton("Unpack nested table").WithTooltip(imgui, recipe.subgroup.expanded ? "Shortcut: right-click" : "Shortcut: Expand, then right-click") && imgui.CloseDropdown()) { + if (recipe.subgroup != null && imgui.BuildButton(LSs.ProductionTableUnpackNested).WithTooltip(imgui, recipe.subgroup.expanded ? LSs.ProductionTableShortcutRightClick : LSs.ProductionTableShortcutExpandAndRightClick) && imgui.CloseDropdown()) { unpackNestedTable(); } - if (recipe.subgroup != null && imgui.BuildButton("ShoppingList") && imgui.CloseDropdown()) { + if (recipe.subgroup != null && imgui.BuildButton(LSs.ShoppingList) && imgui.CloseDropdown()) { view.BuildShoppingList(recipe); } - if (imgui.BuildCheckBox("Show total Input/Output", recipe.showTotalIO, out bool newShowTotalIO)) { + if (imgui.BuildCheckBox(LSs.ProductionTableShowTotalIo, recipe.showTotalIO, out bool newShowTotalIO)) { recipe.RecordUndo().showTotalIO = newShowTotalIO; } - if (imgui.BuildCheckBox("Enabled", recipe.enabled, out bool newEnabled)) { + if (imgui.BuildCheckBox(LSs.Enabled, recipe.enabled, out bool newEnabled)) { recipe.RecordUndo().enabled = newEnabled; } - BuildFavorites(imgui, recipe.recipe.target, "Add recipe to favorites"); + BuildFavorites(imgui, recipe.recipe.target, LSs.AddRecipeToFavorites); - if (recipe.subgroup != null && imgui.BuildRedButton("Delete nested table").WithTooltip(imgui, recipe.subgroup.expanded ? "Shortcut: Collapse, then right-click" : "Shortcut: right-click") && imgui.CloseDropdown()) { + if (recipe.subgroup != null && imgui.BuildRedButton(LSs.ProductionTableDeleteNested).WithTooltip(imgui, recipe.subgroup.expanded ? LSs.ProductionTableShortcutCollapseAndRightClick : LSs.ProductionTableShortcutRightClick) && imgui.CloseDropdown()) { _ = recipe.owner.RecordUndo().recipes.Remove(recipe); } - if (recipe.subgroup == null && imgui.BuildRedButton("Delete recipe").WithTooltip(imgui, "Shortcut: right-click") && imgui.CloseDropdown()) { + if (recipe.subgroup == null && imgui.BuildRedButton(LSs.ProductionTableDeleteRecipe).WithTooltip(imgui, LSs.ProductionTableShortcutRightClick) && imgui.CloseDropdown()) { _ = recipe.owner.RecordUndo().recipes.Remove(recipe); } }); @@ -208,32 +211,32 @@ private static void RemoveZeroRecipes(ProductionTable productionTable) { public override void BuildMenu(ImGui gui) { BuildRecipeButton(gui, view.model); - gui.BuildText("Export inputs and outputs to blueprint with constant combinators:", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ProductionTableExportToBlueprint, TextBlockDisplayStyle.WrappedText); using (gui.EnterRow()) { - gui.BuildText("Amount per:"); + gui.BuildText(LSs.ExportBlueprintAmountPer); - if (gui.BuildLink("second") && gui.CloseDropdown()) { + if (gui.BuildLink(LSs.ExportBlueprintAmountPerSecond) && gui.CloseDropdown()) { ExportIo(1f); } - if (gui.BuildLink("minute") && gui.CloseDropdown()) { + if (gui.BuildLink(LSs.ExportBlueprintAmountPerMinute) && gui.CloseDropdown()) { ExportIo(60f); } - if (gui.BuildLink("hour") && gui.CloseDropdown()) { + if (gui.BuildLink(LSs.ExportBlueprintAmountPerHour) && gui.CloseDropdown()) { ExportIo(3600f); } } - if (gui.BuildButton("Remove all zero-building recipes") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableRemoveZeroBuildingRecipes) && gui.CloseDropdown()) { RemoveZeroRecipes(view.model); } - if (gui.BuildRedButton("Clear recipes") && gui.CloseDropdown()) { + if (gui.BuildRedButton(LSs.ProductionTableClearRecipes) && gui.CloseDropdown()) { view.model.RecordUndo().recipes.Clear(); } - if (InputSystem.Instance.control && gui.BuildButton("Add ALL recipes") && gui.CloseDropdown()) { + if (InputSystem.Instance.control && gui.BuildButton(LSs.ProductionTableAddAllRecipes) && gui.CloseDropdown()) { foreach (var recipe in Database.recipes.all) { if (!recipe.IsAccessible()) { continue; @@ -249,10 +252,10 @@ public override void BuildMenu(ImGui gui) { foreach (var quality in Database.qualities.all) { foreach (var product in recipe.products) { - view.CreateLink(view.model, new ObjectWithQuality(product.goods, quality)); + view.CreateLink(view.model, product.goods.With(quality)); } - view.model.AddRecipe(new(recipe, quality), DefaultVariantOrdering); + view.model.AddRecipe(recipe.With(quality), DefaultVariantOrdering); } goodsHaveNoProduction:; } @@ -264,14 +267,14 @@ public override void BuildMenu(ImGui gui) { /// /// The table that will receive the new recipes or technologies, if any are selected 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 (gui.BuildButton(LSs.ProductionTableAddRawRecipe).WithTooltip(gui, LSs.ProductionTableAddTechnologyHint) && gui.CloseDropdown()) { if (InputSystem.Instance.control) { - SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", - r => table.AddRecipe(new(r, Quality.Normal), DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe.target == r), yellowMark: r => table.GetAllRecipes().Any(rr => rr.recipe.target == r)); + SelectMultiObjectPanel.Select(Database.technologies.all, LSs.ProductionTableAddTechnology, + r => table.AddRecipe(r.With(Quality.Normal), DefaultVariantOrdering), checkMark: r => table.recipes.Any(rr => rr.recipe.target == r), yellowMark: r => table.GetAllRecipes().Any(rr => rr.recipe.target == r)); } else { var prodTable = ProductionLinkSummaryScreen.FindProductionTable(table, out List parents); - SelectMultiObjectPanel.SelectWithQuality(Database.recipes.explorable.AsEnumerable(), "Select raw recipe", + SelectMultiObjectPanel.SelectWithQuality(Database.recipes.explorable.AsEnumerable(), LSs.ProductionTableAddRawRecipe, r => table.AddRecipe(r, DefaultVariantOrdering), Quality.Normal, checkMark: r => table.recipes.Any(rr => rr.recipe.target == r), yellowMark: r => prodTable?.GetAllRecipes().Any(rr => rr.recipe.target == r) ?? false); } } @@ -303,7 +306,7 @@ private void ExportIo(float multiplier) { } } - private class EntityColumn(ProductionTableView view) : ProductionTableDataColumn(view, "Entity", 8f) { + private class EntityColumn(ProductionTableView view) : ProductionTableDataColumn(view, LSs.ProductionTableHeaderEntity, 8f) { public override void BuildElement(ImGui gui, RecipeRow recipe) { if (recipe.isOverviewMode) { return; @@ -350,7 +353,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { if (favoriteCrafter != null && recipe.entity?.target != favoriteCrafter) { _ = recipe.RecordUndo(); - recipe.entity = new(favoriteCrafter, recipe.entity?.quality ?? Quality.MaxAccessible); + recipe.entity = favoriteCrafter.With(recipe.entity?.quality ?? Quality.MaxAccessible); if (!recipe.entity.target.energy.fuels.Contains(recipe.fuel?.target)) { recipe.fuel = recipe.entity.target.energy.fuels.AutoSelect(DataUtils.FavoriteFuel).With(Quality.Normal); @@ -377,7 +380,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { view.BuildGoodsIcon(gui, fuel, fuelLink, fuelAmount, ProductDropdownType.Fuel, recipe, recipe.linkRoot, HintLocations.OnProducingRecipes); } else { - if (recipe.recipe == Database.electricityGeneration.As() && recipe.entity.target.factorioType is "solar-panel" or "lightning-attractor") { + if (recipe.recipe == Database.electricityGeneration && recipe.entity.target.factorioType is "solar-panel" or "lightning-attractor") { BuildAccumulatorView(gui, recipe); } } @@ -391,7 +394,7 @@ private static void BuildAccumulatorView(ImGui gui, RecipeRow recipe) { float requiredMj = recipe.entity?.GetCraftingSpeed() * recipe.buildingCount * (70 / 0.7f) ?? 0; // 70 seconds of charge time to last through the night requiredAccumulators = requiredMj / accumulator.AccumulatorCapacity(accumulatorQuality); } - else if (recipe.entity.Is(out IObjectWithQuality? attractor)) { + else if (recipe.entity is IObjectWithQuality attractor) { // Model the storm as rising from 0% to 100% over 30 seconds, staying at 100% for 24 seconds, and decaying over 30 seconds. // I adjusted these until the right answers came out of my Excel model. // TODO(multi-planet): Adjust these numbers based on day length. @@ -434,7 +437,7 @@ private static void BuildAccumulatorView(ImGui gui, RecipeRow recipe) { requiredChargeMw / accumulator.Power(accumulatorQuality)); } - ObjectWithQuality accumulatorWithQuality = new(accumulator, accumulatorQuality); + IObjectWithQuality accumulatorWithQuality = accumulator.With(accumulatorQuality); if (gui.BuildFactorioObjectWithAmount(accumulatorWithQuality, requiredAccumulators, ButtonDisplayStyle.ProductionTableUnscaled) == Click.Left) { ShowAccumulatorDropdown(gui, recipe, accumulator, accumulatorQuality); } @@ -443,7 +446,7 @@ private static void BuildAccumulatorView(ImGui gui, RecipeRow recipe) { private static void ShowAccumulatorDropdown(ImGui gui, RecipeRow recipe, Entity currentAccumulator, Quality accumulatorQuality) => gui.BuildObjectQualitySelectDropDown(Database.allAccumulators, newAccumulator => recipe.RecordUndo().ChangeVariant(currentAccumulator, newAccumulator.target), - new("Select accumulator", ExtraText: x => DataUtils.FormatAmount(x.AccumulatorCapacity(accumulatorQuality), UnitOfMeasure.Megajoule)), + new(LSs.ProductionTableSelectAccumulator, ExtraText: x => DataUtils.FormatAmount(x.AccumulatorCapacity(accumulatorQuality), UnitOfMeasure.Megajoule)), accumulatorQuality, newQuality => recipe.RecordUndo().ChangeVariant(accumulatorQuality, newQuality)); @@ -470,18 +473,18 @@ private static void ShowEntityDropdown(ImGui gui, RecipeRow recipe) { } _ = recipe.RecordUndo(); - recipe.entity = new(sel, quality); + recipe.entity = sel.With(quality); if (!sel.energy.fuels.Contains(recipe.fuel?.target)) { recipe.fuel = recipe.entity.target.energy.fuels.AutoSelect(DataUtils.FavoriteFuel).With(Quality.Normal); } - }, new("Select crafting entity", DataUtils.FavoriteCrafter, ExtraText: x => DataUtils.FormatAmount(x.CraftingSpeed(quality), UnitOfMeasure.Percent))); + }, new(LSs.ProductionTableSelectCraftingEntity, DataUtils.FavoriteCrafter, ExtraText: x => DataUtils.FormatAmount(x.CraftingSpeed(quality), UnitOfMeasure.Percent))); gui.AllocateSpacing(0.5f); if (recipe.fixedBuildings > 0f && (recipe.fixedFuel || recipe.fixedIngredient != null || recipe.fixedProduct != null || !recipe.hierarchyEnabled)) { - ButtonEvent evt = gui.BuildButton("Clear fixed recipe multiplier"); + ButtonEvent evt = gui.BuildButton(LSs.ProductionTableClearFixedMultiplier); if (willResetFixed) { - _ = evt.WithTooltip(gui, "Shortcut: right-click"); + _ = evt.WithTooltip(gui, LSs.ProductionTableShortcutRightClick); } if (evt && gui.CloseDropdown()) { recipe.RecordUndo().fixedBuildings = 0f; @@ -489,23 +492,20 @@ private static void ShowEntityDropdown(ImGui gui, RecipeRow recipe) { } if (recipe.hierarchyEnabled) { - 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)) { + using (gui.EnterRowWithHelpIcon(LSs.ProductionTableFixedBuildingsHint)) { gui.allocator = RectAllocator.RemainingRow; if (recipe.fixedBuildings > 0f && !recipe.fixedFuel && recipe.fixedIngredient == null && recipe.fixedProduct == null) { - ButtonEvent evt = gui.BuildButton("Clear fixed building count"); + ButtonEvent evt = gui.BuildButton(LSs.ProductionTableClearFixedBuildingCount); if (willResetFixed) { - _ = evt.WithTooltip(gui, "Shortcut: right-click"); + _ = evt.WithTooltip(gui, LSs.ProductionTableShortcutRightClick); } if (evt && gui.CloseDropdown()) { recipe.RecordUndo().fixedBuildings = 0f; } } - else if (gui.BuildButton("Set fixed building count") && gui.CloseDropdown()) { + else if (gui.BuildButton(LSs.ProductionTableSetFixedBuildingCount) && gui.CloseDropdown()) { recipe.RecordUndo().fixedBuildings = recipe.buildingCount <= 0f ? 1f : recipe.buildingCount; recipe.fixedFuel = false; recipe.fixedIngredient = null; @@ -515,31 +515,31 @@ private static void ShowEntityDropdown(ImGui gui, RecipeRow recipe) { } } - 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.")) { + using (gui.EnterRowWithHelpIcon(LSs.ProductionTableBuiltBuildingCountHint)) { gui.allocator = RectAllocator.RemainingRow; if (recipe.builtBuildings != null) { - ButtonEvent evt = gui.BuildButton("Clear built building count"); + ButtonEvent evt = gui.BuildButton(LSs.ProductionTableClearBuiltBuildingCount); if (willResetBuilt) { - _ = evt.WithTooltip(gui, "Shortcut: right-click"); + _ = evt.WithTooltip(gui, LSs.ProductionTableShortcutRightClick); } if (evt && gui.CloseDropdown()) { recipe.RecordUndo().builtBuildings = null; } } - else if (gui.BuildButton("Set built building count") && gui.CloseDropdown()) { + else if (gui.BuildButton(LSs.ProductionTableSetBuiltBuildingCount) && gui.CloseDropdown()) { recipe.RecordUndo().builtBuildings = Math.Max(0, Convert.ToInt32(Math.Ceiling(recipe.buildingCount))); recipe.FocusBuiltCountOnNextDraw(); } } if (recipe.entity != null) { - using (gui.EnterRowWithHelpIcon("Generate a blueprint for one of these buildings, with the recipe and internal modules set.")) { + using (gui.EnterRowWithHelpIcon(LSs.ProductionTableGenerateBuildingBlueprintHint)) { gui.allocator = RectAllocator.RemainingRow; - if (gui.BuildButton("Create single building blueprint") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableGenerateBuildingBlueprint) && gui.CloseDropdown()) { BlueprintEntity entity = new BlueprintEntity { index = 1, name = recipe.entity.target.name }; if (!recipe.recipe.Is()) { @@ -566,21 +566,21 @@ private static void ShowEntityDropdown(ImGui gui, RecipeRow recipe) { } if (recipe.recipe.target.crafters.Length > 1) { - BuildFavorites(gui, recipe.entity.target, "Add building to favorites"); + BuildFavorites(gui, recipe.entity.target, LSs.ProductionTableAddBuildingToFavorites); } } }); } public override void BuildMenu(ImGui gui) { - if (gui.BuildButton("Mass set assembler") && gui.CloseDropdown()) { - SelectSingleObjectPanel.Select(Database.allCrafters, "Set assembler for all recipes", set => { + if (gui.BuildButton(LSs.ProductionTableMassSetAssembler) && gui.CloseDropdown()) { + SelectSingleObjectPanel.Select(Database.allCrafters, LSs.ProductionTableMassSetAssembler, set => { DataUtils.FavoriteCrafter.AddToFavorite(set, 10); foreach (var recipe in view.GetRecipesRecursive()) { if (recipe.recipe.target.crafters.Contains(set)) { _ = recipe.RecordUndo(); - recipe.entity = new(set, recipe.entity?.quality ?? Quality.Normal); + recipe.entity = set.With(recipe.entity?.quality ?? Quality.Normal); if (!set.energy.fuels.Contains(recipe.fuel?.target)) { recipe.fuel = recipe.entity.target.energy.fuels.AutoSelect(DataUtils.FavoriteFuel).With(Quality.Normal); @@ -590,14 +590,14 @@ public override void BuildMenu(ImGui gui) { }, DataUtils.FavoriteCrafter); } - if (gui.BuildQualityList(null, out Quality? quality, "Mass set quality") && gui.CloseDropdown()) { + if (gui.BuildQualityList(null, out Quality? quality, LSs.ProductionTableMassSetQuality) && gui.CloseDropdown()) { foreach (RecipeRow recipe in view.GetRecipesRecursive()) { recipe.RecordUndo().entity = recipe.entity?.With(quality); } } - if (gui.BuildButton("Mass set fuel") && gui.CloseDropdown()) { - SelectSingleObjectPanel.SelectWithQuality(Database.goods.all.Where(x => x.fuelValue > 0), "Set fuel for all recipes", set => { + if (gui.BuildButton(LSs.ProductionTableMassSetFuel) && gui.CloseDropdown()) { + SelectSingleObjectPanel.SelectWithQuality(Database.goods.all.Where(x => x.fuelValue > 0), LSs.ProductionTableMassSetFuel, set => { DataUtils.FavoriteFuel.AddToFavorite(set.target, 10); foreach (var recipe in view.GetRecipesRecursive()) { @@ -608,13 +608,13 @@ public override void BuildMenu(ImGui gui) { }, null, DataUtils.FavoriteFuel); } - if (gui.BuildButton("Shopping list") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ShoppingList) && gui.CloseDropdown()) { view.BuildShoppingList(null); } } } - private class IngredientsColumn(ProductionTableView view) : ProductionTableDataColumn(view, "Ingredients", 32f, 16f, 100f, hasMenu: false, nameof(Preferences.ingredientsColumWidth)) { + private class IngredientsColumn(ProductionTableView view) : ProductionTableDataColumn(view, LSs.ProductionTableHeaderIngredients, 32f, 16f, 100f, hasMenu: false, nameof(Preferences.ingredientsColumWidth)) { public override void BuildElement(ImGui gui, RecipeRow recipe) { var grid = gui.EnterInlineGrid(3f, 1f); @@ -636,7 +636,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { } } - private class ProductsColumn(ProductionTableView view) : ProductionTableDataColumn(view, "Products", 12f, 10f, 70f, hasMenu: false, nameof(Preferences.productsColumWidth)) { + private class ProductsColumn(ProductionTableView view) : ProductionTableDataColumn(view, LSs.ProductionTableHeaderProducts, 12f, 10f, 70f, hasMenu: false, nameof(Preferences.productsColumWidth)) { public override void BuildElement(ImGui gui, RecipeRow recipe) { var grid = gui.EnterInlineGrid(3f, 1f); if (recipe.isOverviewMode) { @@ -648,7 +648,7 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { if (recipe.recipe.target is Recipe { preserveProducts: true }) { view.BuildGoodsIcon(gui, goods, link, amount, ProductDropdownType.Product, recipe, recipe.linkRoot, new() { HintLocations = HintLocations.OnConsumingRecipes, - ExtraSpoilInformation = gui => gui.BuildText("This recipe output does not start spoiling until removed from the machine.", TextBlockDisplayStyle.WrappedText) + ExtraSpoilInformation = gui => gui.BuildText(LSs.ProductionTableOutputPreservedInMachine, TextBlockDisplayStyle.WrappedText) }); } else if (percentSpoiled == null) { @@ -656,12 +656,12 @@ public override void BuildElement(ImGui gui, RecipeRow recipe) { } else if (percentSpoiled == 0) { view.BuildGoodsIcon(gui, goods, link, amount, ProductDropdownType.Product, recipe, recipe.linkRoot, - new() { HintLocations = HintLocations.OnConsumingRecipes, ExtraSpoilInformation = gui => gui.BuildText("This recipe output is always fresh.") }); + new() { HintLocations = HintLocations.OnConsumingRecipes, ExtraSpoilInformation = gui => gui.BuildText(LSs.ProductionTableOutputAlwaysFresh) }); } else { view.BuildGoodsIcon(gui, goods, link, amount, ProductDropdownType.Product, recipe, recipe.linkRoot, new() { HintLocations = HintLocations.OnConsumingRecipes, - ExtraSpoilInformation = gui => gui.BuildText($"This recipe output is {DataUtils.FormatAmount(percentSpoiled.Value, UnitOfMeasure.Percent)} spoiled.") + ExtraSpoilInformation = gui => gui.BuildText(LSs.ProductionTableOutputFixedSpoilage.L(DataUtils.FormatAmount(percentSpoiled.Value, UnitOfMeasure.Percent))) }); } } @@ -679,7 +679,7 @@ private class ModulesColumn : ProductionTableDataColumn { private readonly VirtualScrollList moduleTemplateList; private RecipeRow editingRecipeModules = null!; // null-forgiving: This is set as soon as we open a module dropdown. - public ModulesColumn(ProductionTableView view) : base(view, "Modules", 10f, 7f, 16f, widthStorage: nameof(Preferences.modulesColumnWidth)) + public ModulesColumn(ProductionTableView view) : base(view, LSs.ProductionTableHeaderModules, 10f, 7f, 16f, widthStorage: nameof(Preferences.modulesColumnWidth)) => moduleTemplateList = new VirtualScrollList(15f, new Vector2(20f, 2.5f), ModuleTemplateDrawer, collapsible: true); private void ModuleTemplateDrawer(ImGui gui, ProjectModuleTemplate element, int index) { @@ -738,7 +738,7 @@ void drawItem(ImGui gui, IObjectWithQuality? item, int count) { private void ShowModuleTemplateTooltip(ImGui gui, ModuleTemplate template) => gui.ShowTooltip(imGui => { if (!template.IsCompatibleWith(editingRecipeModules)) { - imGui.BuildText("This module template seems incompatible with the recipe or the building", TextBlockDisplayStyle.WrappedText); + imGui.BuildText(LSs.ProductionTableModuleTemplateIncompatible, TextBlockDisplayStyle.WrappedText); } using var grid = imGui.EnterInlineGrid(3f, 1f); @@ -767,7 +767,7 @@ private void ShowModuleDropDown(ImGui gui, RecipeRow recipe) { Quality quality = Quality.Normal; gui.ShowDropDown(dropGui => { - if (recipe.modules != null && dropGui.BuildButton("Use default modules").WithTooltip(dropGui, "Shortcut: right-click") && dropGui.CloseDropdown()) { + if (recipe.modules != null && dropGui.BuildButton(LSs.ProductionTableUseDefaultModules).WithTooltip(dropGui, LSs.ProductionTableShortcutRightClick) && dropGui.CloseDropdown()) { recipe.RemoveFixedModules(); } @@ -783,18 +783,18 @@ private void ShowModuleDropDown(ImGui gui, RecipeRow recipe) { else { _ = dropGui.BuildQualityList(quality, out quality); } - dropGui.BuildInlineObjectListAndButton(modules, m => recipe.SetFixedModule(new(m, quality)), new("Select fixed module", DataUtils.FavoriteModule)); + dropGui.BuildInlineObjectListAndButton(modules, m => recipe.SetFixedModule(m.With(quality)), new(LSs.ProductionTableSelectModules, DataUtils.FavoriteModule)); } if (moduleTemplateList.data.Count > 0) { - dropGui.BuildText("Use module template:", Font.subheader); + dropGui.BuildText(LSs.ProductionTableUseModuleTemplate, Font.subheader); moduleTemplateList.Build(dropGui); } - if (dropGui.BuildButton("Configure module templates") && dropGui.CloseDropdown()) { + if (dropGui.BuildButton(LSs.ProductionTableConfigureModuleTemplates) && dropGui.CloseDropdown()) { ModuleTemplateConfiguration.Show(); } - if (dropGui.BuildButton("Customize modules") && dropGui.CloseDropdown()) { + if (dropGui.BuildButton(LSs.ProductionTableCustomizeModules) && dropGui.CloseDropdown()) { ModuleCustomizationScreen.Show(recipe); } }); @@ -803,15 +803,15 @@ private void ShowModuleDropDown(ImGui gui, RecipeRow recipe) { public override void BuildMenu(ImGui gui) { var model = view.model; - gui.BuildText("Auto modules", Font.subheader); + gui.BuildText(LSs.ProductionTableAutoModules, Font.subheader); ModuleFillerParametersScreen.BuildSimple(gui, model.modules!); // null-forgiving: owner is a ProjectPage, so modules is not null. - if (gui.BuildButton("Module settings") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableModuleSettings) && gui.CloseDropdown()) { ModuleFillerParametersScreen.Show(model.modules!); } } } - public static void BuildFavorites(ImGui imgui, FactorioObject? obj, string prompt) { + public static void BuildFavorites(ImGui imgui, FactorioObject? obj, LocalizableString0 prompt) { if (obj == null) { return; } @@ -819,7 +819,7 @@ public static void BuildFavorites(ImGui imgui, FactorioObject? obj, string promp bool isFavorite = Project.current.preferences.favorites.Contains(obj); using (imgui.EnterRow(0.5f, RectAllocator.LeftRow)) { imgui.BuildIcon(isFavorite ? Icon.StarFull : Icon.StarEmpty); - imgui.RemainingRow().BuildText(isFavorite ? "Favorite" : prompt); + imgui.RemainingRow().BuildText(isFavorite ? LSs.Favorite : prompt); } if (imgui.OnClick(imgui.lastRect)) { Project.current.preferences.ToggleFavorite(obj); @@ -846,7 +846,7 @@ private void CreateLink(ProductionTable table, IObjectWithQuality goods) return; } - ProductionLink link = new ProductionLink(table, new(goods.target, goods.quality)); + ProductionLink link = new ProductionLink(table, goods.target.With(goods.quality)); Rebuild(); table.RecordUndo().links.Add(link); } @@ -858,7 +858,7 @@ private void DestroyLink(ProductionLink link) { } } - private static void CreateNewProductionTable(ObjectWithQuality goods, float amount) { + private static void CreateNewProductionTable(IObjectWithQuality goods, float amount) { var page = MainScreen.Instance.AddProjectPage(goods.target.locName, goods.target, typeof(ProductionTable), true, false); ProductionTable content = (ProductionTable)page.content; ProductionLink link = new ProductionLink(content, goods) { amount = amount > 0 ? amount : 1 }; @@ -866,7 +866,7 @@ private static void CreateNewProductionTable(ObjectWithQuality goods, flo content.RebuildLinkMap(); } - private void OpenProductDropdown(ImGui targetGui, Rect rect, ObjectWithQuality goods, float amount, IProductionLink? iLink, + private void OpenProductDropdown(ImGui targetGui, Rect rect, IObjectWithQuality goods, float amount, IProductionLink? iLink, ProductDropdownType type, RecipeRow? recipe, ProductionTable context, Goods[]? variants = null) { if (InputSystem.Instance.shift) { @@ -883,18 +883,18 @@ private void OpenProductDropdown(ImGui targetGui, Rect rect, ObjectWithQuality curLevelRecipes.Contains(rec.With(goods.quality)); bool recipeExistsAnywhere(RecipeOrTechnology rec) => allRecipes.Contains(rec.With(goods.quality)); - ObjectWithQuality? selectedFuel = null; - ObjectWithQuality? spentFuel = null; + IObjectWithQuality? selectedFuel = null; + IObjectWithQuality? spentFuel = null; async void addRecipe(RecipeOrTechnology rec) { - ObjectWithQuality qualityRecipe = rec.With(goods.quality); + IObjectWithQuality qualityRecipe = rec.With(goods.quality); if (variants == null) { CreateLink(context, goods); } else { foreach (var variant in variants) { if (rec.GetProductionPerRecipe(variant) > 0f) { - CreateLink(context, new ObjectWithQuality(variant, goods.quality)); + CreateLink(context, variant.With(goods.quality)); if (variant != goods.target) { // null-forgiving: If variants is not null, neither is recipe: Only the call from BuildGoodsIcon sets variants, @@ -907,7 +907,7 @@ async void addRecipe(RecipeOrTechnology rec) { } } - if (!curLevelRecipes.Contains(qualityRecipe) || (await MessageBox.Show("Recipe already exists", $"Add a second copy of {rec.locName}?", "Add a copy", "Cancel")).choice) { + if (!curLevelRecipes.Contains(qualityRecipe) || (await MessageBox.Show(LSs.ProductionTableAlertRecipeExists, LSs.ProductionTableQueryAddCopy.L(rec.locName), LSs.ProductionTableAddCopy, LSs.Cancel)).choice) { context.AddRecipe(qualityRecipe, DefaultVariantOrdering, selectedFuel, spentFuel); } } @@ -940,21 +940,21 @@ void dropDownContent(ImGui gui) { EntityEnergy? energy = recipe.entity.target.energy; if (energy == null || energy.fuels.Length == 0) { - gui.BuildText("This entity has no known fuels"); + gui.BuildText(LSs.ProductionTableAlertNoKnownFuels); } else if (energy.fuels.Length > 1 || energy.fuels[0] != recipe.fuel?.target) { Func fuelDisplayFunc = energy.type == EntityEnergyType.FluidHeat ? g => DataUtils.FormatAmount(g.fluid?.heatValue ?? 0, UnitOfMeasure.Megajoule) : g => DataUtils.FormatAmount(g.fuelValue, UnitOfMeasure.Megajoule); - BuildFavorites(gui, recipe.fuel!.target, "Add fuel to favorites"); + BuildFavorites(gui, recipe.fuel!.target, LSs.ProductionTableAddFuelToFavorites); gui.BuildInlineObjectListAndButton(energy.fuels, fuel => recipe.RecordUndo().fuel = fuel.With(Quality.Normal), - new("Select fuel", DataUtils.FavoriteFuel, ExtraText: fuelDisplayFunc)); + new(LSs.ProductionTableSelectFuel, DataUtils.FavoriteFuel, ExtraText: fuelDisplayFunc)); } } if (variants != null) { - gui.BuildText("Accepted fluid variants:"); + gui.BuildText(LSs.ProductionTableAcceptedFluids); using (var grid = gui.EnterInlineGrid(3f)) { foreach (var variant in variants) { grid.Next(); @@ -987,14 +987,14 @@ void dropDownContent(ImGui gui) { #region Recipe selection int numberOfShownRecipes = 0; - if (goods == Database.science.As()) { - if (gui.BuildButton("Add technology") && gui.CloseDropdown()) { - SelectMultiObjectPanel.Select(Database.technologies.all, "Select technology", - r => context.AddRecipe(new(r, Quality.Normal), DefaultVariantOrdering), checkMark: r => context.recipes.Any(rr => rr.recipe.target == r)); + if (goods == Database.science) { + if (gui.BuildButton(LSs.ProductionTableAddTechnology) && gui.CloseDropdown()) { + SelectMultiObjectPanel.Select(Database.technologies.all, LSs.ProductionTableAddTechnology, + r => context.AddRecipe(r.With(Quality.Normal), DefaultVariantOrdering), checkMark: r => context.recipes.Any(rr => rr.recipe.target == r)); } } else if (type <= ProductDropdownType.Ingredient && allProduction.Length > 0) { - gui.BuildInlineObjectListAndButton(allProduction, addRecipe, new("Add production recipe", comparer, 6, true, recipeExists, recipeExistsAnywhere)); + gui.BuildInlineObjectListAndButton(allProduction, addRecipe, new(LSs.ProductionTableAddProductionRecipe, comparer, 6, true, recipeExists, recipeExistsAnywhere)); numberOfShownRecipes += allProduction.Length; if (iLink == null) { @@ -1006,7 +1006,7 @@ void dropDownContent(ImGui gui) { CreateNewProductionTable(goods, amount); } else if (evt == ButtonEvent.MouseOver) { - gui.ShowTooltip(iconRect, "Create new production table for " + goods.target.locName); + gui.ShowTooltip(iconRect, LSs.ProductionTableCreateTableFor.L(goods.target.locName)); } } } @@ -1015,7 +1015,7 @@ void dropDownContent(ImGui gui) { gui.BuildInlineObjectListAndButton( spentFuelRecipes, (x) => { spentFuel = goods; addRecipe(x); }, - new("Produce it as a spent fuel", + new(LSs.ProductionTableProduceAsSpentFuel, DataUtils.AlreadySortedRecipe, 3, true, @@ -1028,7 +1028,7 @@ void dropDownContent(ImGui gui) { gui.BuildInlineObjectListAndButton( goods.target.usages, addRecipe, - new("Add consumption recipe", + new(LSs.ProductionTableAddConsumptionRecipe, DataUtils.DefaultRecipeOrdering, 6, true, @@ -1041,7 +1041,7 @@ void dropDownContent(ImGui gui) { gui.BuildInlineObjectListAndButton( fuelUseList, (x) => { selectedFuel = goods; addRecipe(x); }, - new("Add fuel usage", + new(LSs.ProductionTableAddFuelUsage, DataUtils.AlreadySortedRecipe, 6, true, @@ -1051,74 +1051,68 @@ void dropDownContent(ImGui gui) { } if (type >= ProductDropdownType.Product && Database.allSciencePacks.Contains(goods.target) - && gui.BuildButton("Add consumption technology") && gui.CloseDropdown()) { + && gui.BuildButton(LSs.ProductionTableAddConsumptionTechnology) && 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.target)), - "Add technology", addRecipe, checkMark: recipeExists, yellowMark: recipeExistsAnywhere); + LSs.ProductionTableAddTechnology, addRecipe, checkMark: recipeExists, yellowMark: recipeExistsAnywhere); } if (type >= ProductDropdownType.Product && allProduction.Length > 0) { - gui.BuildInlineObjectListAndButton(allProduction, addRecipe, new("Add production recipe", comparer, 1, true, recipeExists, recipeExistsAnywhere)); + gui.BuildInlineObjectListAndButton(allProduction, addRecipe, new(LSs.ProductionTableAddProductionRecipe, comparer, 1, true, recipeExists, recipeExistsAnywhere)); numberOfShownRecipes += allProduction.Length; } if (numberOfShownRecipes > 1) { - gui.BuildText("Hint: ctrl+click to add multiple", TextBlockDisplayStyle.HintText); + gui.BuildText(LSs.ProductionTableAddMultipleHint, TextBlockDisplayStyle.HintText); } #endregion #region Link management ProductionLink? link = iLink as ProductionLink; - if (link != null && gui.BuildCheckBox("Allow overproduction", link.algorithm == LinkAlgorithm.AllowOverProduction, out bool newValue)) { + if (link != null && gui.BuildCheckBox(LSs.ProductionTableAllowOverproduction, link.algorithm == LinkAlgorithm.AllowOverProduction, out bool newValue)) { link.RecordUndo().algorithm = newValue ? LinkAlgorithm.AllowOverProduction : LinkAlgorithm.Match; } - if (iLink != null && gui.BuildButton("View link summary") && gui.CloseDropdown()) { + if (iLink != null && gui.BuildButton(LSs.ProductionTableViewLinkSummary) && gui.CloseDropdown()) { ProductionLinkSummaryScreen.Show(iLink.DisplayLink); } if (link != null && link.owner == context) { if (link.amount != 0) { - gui.BuildText(goods.target.locName + " is a desired product and cannot be unlinked.", TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ProductionTableCannotUnlink.L(goods.target.locName), TextBlockDisplayStyle.WrappedText); } else { - string goodProdLinkedMessage = goods.target.locName + " production is currently linked. This means that YAFC will try to match production with consumption."; - gui.BuildText(goodProdLinkedMessage, TextBlockDisplayStyle.WrappedText); + gui.BuildText(LSs.ProductionTableCurrentlyLinked.L(goods.target.locName), TextBlockDisplayStyle.WrappedText); } if (type is ProductDropdownType.DesiredIngredient or ProductDropdownType.DesiredProduct) { - if (gui.BuildButton("Remove desired product") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableRemoveDesiredProduct) && gui.CloseDropdown()) { link.RecordUndo().amount = 0; } - if (gui.BuildButton("Remove and unlink").WithTooltip(gui, "Shortcut: right-click") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableRemoveAndUnlinkDesiredProduct).WithTooltip(gui, LSs.ProductionTableShortcutRightClick) && gui.CloseDropdown()) { DestroyLink(link); } } - else if (link.amount == 0 && gui.BuildButton("Unlink").WithTooltip(gui, "Shortcut: right-click") && gui.CloseDropdown()) { + else if (link.amount == 0 && gui.BuildButton(LSs.ProductionTableUnlink).WithTooltip(gui, LSs.ProductionTableShortcutRightClick) && gui.CloseDropdown()) { DestroyLink(link); } } else if (goods != null) { if (link != null) { - string goodsNestLinkMessage = goods.target.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); + gui.BuildText(LSs.ProductionTableLinkedInParent.L(goods.target.locName), TextBlockDisplayStyle.WrappedText); } else if (iLink != null) { - string implicitLink = goods.target.locName + $" ({goods.quality.locName}) production is implicitly linked. This means that YAFC will use it, " + - $"along with all other available qualities, to produce {Database.science.target.locName}.\n" + - $"You may add a regular link to replace this implicit link."; + string implicitLink = LSs.ProductionTableImplicitlyLinked.L(goods.target.locName, goods.quality.locName); gui.BuildText(implicitLink, TextBlockDisplayStyle.WrappedText); - if (gui.BuildButton("Create link").WithTooltip(gui, "Shortcut: right-click") && gui.CloseDropdown()) { + if (gui.BuildButton(LSs.ProductionTableCreateLink).WithTooltip(gui, LSs.ProductionTableShortcutRightClick) && gui.CloseDropdown()) { CreateLink(context, goods); } } else if (goods.target.isLinkable) { - string notLinkedMessage = goods.target.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()) { + gui.BuildText(LSs.ProductionTableNotLinked.L(goods.target.locName), TextBlockDisplayStyle.WrappedText); + if (gui.BuildButton(LSs.ProductionTableCreateLink).WithTooltip(gui, LSs.ProductionTableShortcutRightClick) && gui.CloseDropdown()) { CreateLink(context, goods); } } @@ -1132,9 +1126,9 @@ void dropDownContent(ImGui gui) { || (type == ProductDropdownType.Ingredient && recipe.fixedIngredient != goods) || (type == ProductDropdownType.Product && recipe.fixedProduct != goods)) { string? prompt = type switch { - ProductDropdownType.Fuel => "Set fixed fuel consumption", - ProductDropdownType.Ingredient => "Set fixed ingredient consumption", - ProductDropdownType.Product => "Set fixed production amount", + ProductDropdownType.Fuel => LSs.ProductionTableSetFixedFuel, + ProductDropdownType.Ingredient => LSs.ProductionTableSetFixedIngredient, + ProductDropdownType.Product => LSs.ProductionTableSetFixedProduct, _ => null }; if (prompt != null) { @@ -1143,7 +1137,7 @@ void dropDownContent(ImGui gui) { evt = gui.BuildButton(prompt); } else { - using (gui.EnterRowWithHelpIcon("This will replace the other fixed amount in this row.")) { + using (gui.EnterRowWithHelpIcon(LSs.ProductionTableSetFixedWillReplace)) { gui.allocator = RectAllocator.RemainingRow; evt = gui.BuildButton(prompt); } @@ -1180,9 +1174,9 @@ void dropDownContent(ImGui gui) { || (type == ProductDropdownType.Ingredient && recipe.fixedIngredient == goods) || (type == ProductDropdownType.Product && recipe.fixedProduct == goods))) { string? prompt = type switch { - ProductDropdownType.Fuel => "Clear fixed fuel consumption", - ProductDropdownType.Ingredient => "Clear fixed ingredient consumption", - ProductDropdownType.Product => "Clear fixed production amount", + ProductDropdownType.Fuel => LSs.ProductionTableClearFixedFuel, + ProductDropdownType.Ingredient => LSs.ProductionTableClearFixedIngredient, + ProductDropdownType.Product => LSs.ProductionTableClearFixedProduct, _ => null }; if (prompt != null && gui.BuildButton(prompt) && gui.CloseDropdown()) { @@ -1193,8 +1187,8 @@ void dropDownContent(ImGui gui) { } #endregion - if (goods.Is()) { - BuildBeltInserterInfo(gui, amount, recipe?.buildingCount ?? 0); + if (goods is { target: Item item }) { + BuildBeltInserterInfo(gui, item, amount, recipe?.buildingCount ?? 0); } } } @@ -1318,7 +1312,7 @@ private void BuildGoodsIcon(ImGui gui, IObjectWithQuality? goods, IProduc switch (evt) { case GoodsWithAmountEvent.LeftButtonClick when goods is not null: - OpenProductDropdown(gui, gui.lastRect, new(goods.target, goods.quality), amount, link, dropdownType, recipe, context, variants); + OpenProductDropdown(gui, gui.lastRect, goods, amount, link, dropdownType, recipe, context, variants); break; case GoodsWithAmountEvent.RightButtonClick when goods is not null and { target.isLinkable: true } && (link is not ProductionLink || link.owner != context): CreateLink(context, goods); @@ -1408,7 +1402,7 @@ private static List GetRecipesRecursive(RecipeRow recipeRoot) { private void BuildShoppingList(RecipeRow? recipeRoot) => ShoppingListScreen.Show(recipeRoot == null ? GetRecipesRecursive() : GetRecipesRecursive(recipeRoot)); - private static void BuildBeltInserterInfo(ImGui gui, float amount, float buildingCount) { + private static void BuildBeltInserterInfo(ImGui gui, Item item, float amount, float buildingCount) { var preferences = Project.current.preferences; var belt = preferences.defaultBelt; var inserter = preferences.defaultInserter; @@ -1426,18 +1420,19 @@ private static void BuildBeltInserterInfo(ImGui gui, float amount, float buildin gui.BuildText(DataUtils.FormatAmount(beltCount, UnitOfMeasure.None)); if (buildingsPerHalfBelt > 0f) { - gui.BuildText("(Buildings per half belt: " + DataUtils.FormatAmount(buildingsPerHalfBelt, UnitOfMeasure.None) + ")"); + gui.BuildText(LSs.ProductionTableBuildingsPerHalfBelt.L(DataUtils.FormatAmount(buildingsPerHalfBelt, UnitOfMeasure.None))); } } using (gui.EnterRow()) { - int capacity = preferences.inserterCapacity; + int capacity = Math.Min(item.stackSize, preferences.inserterCapacity); 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)"; + text = LSs.ProductionTableInsertersPerBuilding.L(DataUtils.FormatAmount(inserterBase, UnitOfMeasure.None), + DataUtils.FormatAmount(inserterBase / buildingCount, UnitOfMeasure.None)); } gui.BuildText(text); @@ -1448,10 +1443,11 @@ private static void BuildBeltInserterInfo(ImGui gui, float amount, float buildin click |= gui.BuildFactorioObjectButton(belt, ButtonDisplayStyle.Default) == Click.Left; gui.AllocateSpacing(-1.5f); click |= gui.BuildFactorioObjectButton(inserter, ButtonDisplayStyle.Default) == Click.Left; - text = DataUtils.FormatAmount(inserterToBelt, UnitOfMeasure.None, "~"); + text = LSs.ProductionTableApproximateInserters.L(DataUtils.FormatAmount(inserterToBelt, UnitOfMeasure.None)); if (buildingCount > 1) { - text += " (" + DataUtils.FormatAmount(inserterToBelt / buildingCount, UnitOfMeasure.None) + "/b)"; + text = LSs.ProductionTableApproximateInsertersPerBuilding.L(DataUtils.FormatAmount(inserterToBelt, UnitOfMeasure.None), + DataUtils.FormatAmount(inserterToBelt / buildingCount, UnitOfMeasure.None)); } gui.BuildText(text); @@ -1530,27 +1526,24 @@ protected override void BuildPageTooltip(ImGui gui, ProductionTable contents) { } } - private static readonly Dictionary WarningsMeaning = new Dictionary + private static readonly Dictionary WarningsMeaning = new() { - {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.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."}, - {WarningFlags.FuelTemperatureExceedsMaximum, "Fluid temperature is higher than generator maximum. Some energy is wasted."}, - {WarningFlags.FuelDoesNotProvideEnergy, "This fuel cannot provide any energy to this building. The building won't work."}, - {WarningFlags.FuelUsageInputLimited, "This building has max fuel consumption. The rate at which it works is limited by it."}, - {WarningFlags.TemperatureForIngredientNotMatch, "This recipe does care about ingredient temperature, and the temperature range does not match"}, - {WarningFlags.ReactorsNeighborsFromPrefs, "Assumes reactor formation from preferences. (Click to open the preferences)"}, - {WarningFlags.AssumesNauvisSolarRatio, "Energy production values assumes Nauvis solar ration (70% power output). Don't forget accumulators."}, - {WarningFlags.ExceedsBuiltCount, "This recipe requires more buildings than are currently built."}, - {WarningFlags.AsteroidCollectionNotModelled, "The speed of asteroid collectors depends heavily on location and travel speed. " + - "It also depends on the distance between adjacent collectors. These dependencies are not modeled. Expect widely varied performance."}, - {WarningFlags.AssumesFulgoraAndModel, "Energy production values assume Fulgoran storms and attractors in a square grid.\n" + - "The accumulator estimate tries to store 10% of the energy captured by the attractors."}, - {WarningFlags.UselessQuality, "The quality bonus on this recipe has no effect. " + - "Make sure the recipe produces items and that all milestones for the next quality are unlocked. (Click to open the milestone window)"}, + {WarningFlags.DeadlockCandidate, LSs.WarningDescriptionDeadlockCandidate}, + {WarningFlags.OverproductionRequired, LSs.WarningDescriptionOverproductionRequired}, + {WarningFlags.EntityNotSpecified, LSs.WarningDescriptionEntityNotSpecified}, + {WarningFlags.FuelNotSpecified, LSs.WarningDescriptionFuelNotSpecified}, + {WarningFlags.FuelWithTemperatureNotLinked, LSs.WarningDescriptionFluidWithTemperature}, + {WarningFlags.FuelTemperatureExceedsMaximum, LSs.WarningDescriptionFluidTooHot}, + {WarningFlags.FuelDoesNotProvideEnergy, LSs.WarningDescriptionFuelDoesNotProvideEnergy}, + {WarningFlags.FuelUsageInputLimited, LSs.WarningDescriptionHasMaxFuelConsumption}, + {WarningFlags.TemperatureForIngredientNotMatch, LSs.WarningDescriptionIngredientTemperatureRange}, + {WarningFlags.ReactorsNeighborsFromPrefs, LSs.WarningDescriptionAssumesReactorFormation}, + {WarningFlags.AssumesNauvisSolarRatio, LSs.WarningDescriptionAssumesNauvisSolar}, + {WarningFlags.ExceedsBuiltCount, LSs.WarningDescriptionNeedsMoreBuildings}, + {WarningFlags.AsteroidCollectionNotModelled, LSs.WarningDescriptionAsteroidCollectors}, + {WarningFlags.AssumesFulgoraAndModel, LSs.WarningDescriptionAssumesFulgoranLightning}, + {WarningFlags.UselessQuality, LSs.WarningDescriptionUselessQuality}, + {WarningFlags.ExcessProductivity, LSs.WarningDescriptionExcessProductivityBonus}, }; private static readonly (Icon icon, SchemeColor color)[] tagIcons = [ @@ -1577,7 +1570,7 @@ protected override void BuildContent(ImGui gui) { } private static void AddDesiredProductAtLevel(ProductionTable table) => SelectMultiObjectPanel.SelectWithQuality( - Database.goods.all.Except(table.linkMap.Where(p => p.Value.amount != 0).Select(p => p.Key.target)).Where(g => g.isLinkable), "Add desired product", product => { + Database.goods.all.Except(table.linkMap.Where(p => p.Value.amount != 0).Select(p => p.Key.target)).Where(g => g.isLinkable), LSs.ProductionTableAddDesiredProduct, product => { if (table.linkMap.TryGetValue(product, out var existing) && existing is ProductionLink link) { if (link.amount != 0) { return; @@ -1586,7 +1579,7 @@ private static void AddDesiredProductAtLevel(ProductionTable table) => SelectMul link.RecordUndo().amount = 1f; } else { - table.RecordUndo().links.Add(new ProductionLink(table, new(product.target, product.quality)) { amount = 1f }); + table.RecordUndo().links.Add(new ProductionLink(table, product.target.With(product.quality)) { amount = 1f }); } }, Quality.Normal); @@ -1600,7 +1593,7 @@ private void BuildSummary(ImGui gui, ProductionTable table) { gui.spacing = 1f; Padding pad = new Padding(1f, 0.2f); using (gui.EnterGroup(pad)) { - gui.BuildText("Desired products and amounts (Use negative for input goal):"); + gui.BuildText(LSs.ProductionTableDesiredProducts); using var grid = gui.EnterInlineGrid(3f, 1f, elementsPerRow); foreach (var link in table.links.ToList()) { if (link.amount != 0f) { @@ -1621,7 +1614,7 @@ private void BuildSummary(ImGui gui, ProductionTable table) { if (table.flow.Length > 0 && table.flow[0].amount < 0) { using (gui.EnterGroup(pad)) { - gui.BuildText(isRoot ? "Summary ingredients:" : "Import ingredients:"); + gui.BuildText(isRoot ? LSs.ProductionTableSummaryIngredients : LSs.ProductionTableImportIngredients); var grid = gui.EnterInlineGrid(3f, 1f, elementsPerRow); BuildTableIngredients(gui, table, table, ref grid); grid.Dispose(); @@ -1637,7 +1630,7 @@ private void BuildSummary(ImGui gui, ProductionTable table) { ImGuiUtils.InlineGridBuilder grid = default; void initializeGrid(ImGui gui) { context = gui.EnterGroup(pad); - gui.BuildText(isRoot ? "Extra products:" : "Export products:"); + gui.BuildText(isRoot ? LSs.ProductionTableExtraProducts : LSs.ProductionTableExportProducts); grid = gui.EnterInlineGrid(3f, 1f, elementsPerRow); } diff --git a/Yafc/Workspace/SummaryView.cs b/Yafc/Workspace/SummaryView.cs index 30ac7c6d..c204797c 100644 --- a/Yafc/Workspace/SummaryView.cs +++ b/Yafc/Workspace/SummaryView.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Numerics; using System.Threading.Tasks; +using Yafc.I18n; using Yafc.Model; using Yafc.UI; @@ -26,7 +27,7 @@ private class SummaryScrollArea(GuiBuilder builder) : ScrollArea(DefaultHeight, } private class SummaryTabColumn : TextDataColumn { - public SummaryTabColumn() : base("Tab", firstColumnWidth) { + public SummaryTabColumn() : base(LSs.Page, firstColumnWidth) { } public override void BuildElement(ImGui gui, ProjectPage page) { @@ -51,7 +52,7 @@ public override void BuildElement(ImGui gui, ProjectPage page) { } } - private sealed class SummaryDataColumn(SummaryView view) : TextDataColumn("Linked", float.MaxValue) { + private sealed class SummaryDataColumn(SummaryView view) : TextDataColumn(LSs.SummaryColumnLinked, float.MaxValue) { public override void BuildElement(ImGui gui, ProjectPage page) { if (page?.contentType != typeof(ProductionTable)) { return; @@ -232,7 +233,7 @@ protected override void BuildHeader(ImGui gui) { base.BuildHeader(gui); gui.allocator = RectAllocator.Center; - gui.BuildText("Production Sheet Summary", new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); + gui.BuildText(LSs.SummaryHeader, new TextBlockDisplayStyle(Font.header, Alignment: RectAlignment.Middle)); gui.allocator = RectAllocator.LeftAlign; } @@ -259,13 +260,13 @@ protected override async void BuildContent(ImGui gui) { using (gui.EnterRow()) { _ = gui.AllocateRect(0, 2); // Increase row height to 2, for vertical centering. - if (gui.BuildCheckBox("Only show issues", model?.showOnlyIssues ?? false, out bool newValue)) { + if (gui.BuildCheckBox(LSs.SummaryOnlyShowIssues, 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(); } - using (gui.EnterRowWithHelpIcon("Attempt to match production and consumption of all linked products on the displayed pages.\n\nYou will often have to click this button multiple times to fully balance production.", false)) { - if (gui.BuildButton("Auto balance")) { + using (gui.EnterRowWithHelpIcon(LSs.SummaryAutoBalanceHint, false)) { + if (gui.BuildButton(LSs.SummaryAutoBalance)) { await AutoBalance(); } } diff --git a/Yafc/Yafc.csproj b/Yafc/Yafc.csproj index 434208d4..9b9dd117 100644 --- a/Yafc/Yafc.csproj +++ b/Yafc/Yafc.csproj @@ -3,8 +3,8 @@ WinExe net8.0 win-x64;linux-x64;osx-x64;osx-arm64 - 2.11.0 - 2.11.0 + 2.11.1 + 2.11.1 true image.ico enable diff --git a/build.sh b/build.sh index a030c274..86c62a53 100755 --- a/build.sh +++ b/build.sh @@ -8,6 +8,9 @@ rm -rf Build VERSION=$(grep -oPm1 "(?<=)[^<]+" Yafc/Yafc.csproj) echo "Building YAFC version $VERSION..." +# For reasons that are unclear, the build fails if the generated files do not exist at the beginning of the build. +# Explicitly build the generator to generate the files, then let the implicit builds take care of everything else. +dotnet build ./Yafc.I18n.Generator/ dotnet publish Yafc/Yafc.csproj -r win-x64 -c Release -o Build/Windows dotnet publish Yafc/Yafc.csproj -r win-x64 --self-contained -c Release -o Build/Windows-self-contained dotnet publish Yafc/Yafc.csproj -r osx-x64 -c Release -o Build/OSX diff --git a/changelog.txt b/changelog.txt index b90e5b6e..c0d65faf 100644 --- a/changelog.txt +++ b/changelog.txt @@ -15,6 +15,31 @@ // Internal changes: // Changes to the code that do not affect the behavior of the program. ---------------------------------------------------------------------------------------------------------------------- +Version: +Date: + Features: + - When changing the language for Factorio objects, automatically switch to, or download, a font that can + display that language. + - All of YAFC can be translated, not just the strings that are used in Factorio. + Fixes: + - Prevent productivity bonuses from exceeding +300%, unless otherwise allowed by mods. + - When loading duplicate research productivity effects, obey both of them, instead of failing. + - Fixed a crash when item.weight == 0, related to ultracube. + - If the requested mod version isn't found, use the latest, like Factorio. + - Fix amounts loaded from other pages in the legacy summary page. + - Improved precision for building count calculation. + - Remove automatic catalyst amount calculations when loading Factorio 2.0 data. + - Update documentation for changing the selected Factorio-object language. +---------------------------------------------------------------------------------------------------------------------- +Version: 2.11.1 +Date: April 5th 2025 + Fixes: + - (regression) Fix opening new unnamed files. + - (.NET 9) Fix page name display in the search-all dropdown. + - When calculating the required inserters, remember that inserters cannot hold more than one stack. + Internal changes: + - Quality objects now have reference equality and abstract serialization, like FactorioObjects. +---------------------------------------------------------------------------------------------------------------------- Version: 2.11.0 Date: March 21st 2025 Features: diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 00000000..9dc78157 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,20 @@ +# This file has the info necessary for the setup of the Crowdin github Action. +# The info was taken from the page of the github Action: https://github.com/marketplace/actions/crowdin-action + +# By default, the Action will look for the crowdin.yml file in the root of the repository, so I put this file in there. +# Feel free to discuss moving it elsewhere if you can think of a better location. +# You can specify a different path using the "config" option in this file. + +"project_id_env": "CROWDIN_PROJECT_ID" +"api_token_env": "CROWDIN_PERSONAL_TOKEN" +"base_path": "." + +"preserve_hierarchy": true + +"files": [ + { + "source": "Yafc/Data/locale/en/*.cfg", + "dest": "%file_name%.ini", + "translation": "Yafc/Data/locale/%two_letters_code%/%file_name%.cfg" + } +] diff --git a/licenses.txt b/licenses.txt index 1056f361..207abefd 100644 --- a/licenses.txt +++ b/licenses.txt @@ -908,6 +908,103 @@ https://github.com/googlefonts/roboto limitations under the License. +### Noto Sans font family ### +https://github.com/notofonts + + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. + + ### Google material design icons ### https://github.com/google/material-design-icons