diff options
40 files changed, 721 insertions, 164 deletions
diff --git a/build/common.targets b/build/common.targets index 1f6cf0b6..02142351 100644 --- a/build/common.targets +++ b/build/common.targets @@ -1,7 +1,7 @@ <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> <PropertyGroup> <!--set general build properties --> - <Version>3.12.7</Version> + <Version>3.12.8</Version> <Product>SMAPI</Product> <LangVersion>latest</LangVersion> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> @@ -54,13 +54,13 @@ <Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)\i18n" /> </Target> - <Target Name="CopyToolkit" Condition="'$(MSBuildProjectName)' == 'SMAPI.Toolkit' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent"> + <Target Name="CopyToolkit" Condition="'$(MSBuildProjectName)' == 'SMAPI.Toolkit' AND $(TargetFramework) == 'net452'" AfterTargets="PostBuildEvent"> <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\smapi-internal" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\smapi-internal" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)\smapi-internal" /> </Target> - <Target Name="CopyToolkitCoreInterfaces" Condition="'$(MSBuildProjectName)' == 'SMAPI.Toolkit.CoreInterfaces' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent"> + <Target Name="CopyToolkitCoreInterfaces" Condition="'$(MSBuildProjectName)' == 'SMAPI.Toolkit.CoreInterfaces' AND $(TargetFramework) == 'net452'" AfterTargets="PostBuildEvent"> <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\smapi-internal" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\smapi-internal" /> <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)\smapi-internal" /> diff --git a/build/prepare-install-package.targets b/build/prepare-install-package.targets index 88e565f9..601f6496 100644 --- a/build/prepare-install-package.targets +++ b/build/prepare-install-package.targets @@ -14,7 +14,7 @@ <OutRootPath>$(SolutionDir)\..\bin</OutRootPath> <SmapiBin>$(BuildRootPath)\SMAPI\bin\$(Configuration)</SmapiBin> - <ToolkitBin>$(BuildRootPath)\SMAPI.Toolkit\bin\$(Configuration)\net4.5</ToolkitBin> + <ToolkitBin>$(BuildRootPath)\SMAPI.Toolkit\bin\$(Configuration)\net452</ToolkitBin> <ConsoleCommandsBin>$(BuildRootPath)\SMAPI.Mods.ConsoleCommands\bin\$(Configuration)</ConsoleCommandsBin> <ErrorHandlerBin>$(BuildRootPath)\SMAPI.Mods.ErrorHandler\bin\$(Configuration)</ErrorHandlerBin> <SaveBackupBin>$(BuildRootPath)\SMAPI.Mods.SaveBackup\bin\$(Configuration)</SaveBackupBin> diff --git a/docs/README.md b/docs/README.md index e7b8a9b3..ecfa6f2b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -66,7 +66,12 @@ Hungarian | ✓ [fully translated](../src/SMAPI/i18n/hu.json) Italian | ✓ [fully translated](../src/SMAPI/i18n/it.json) Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json) Korean | ✓ [fully translated](../src/SMAPI/i18n/ko.json) +Polish¹ | ✓ [fully translated](../src/SMAPI/i18n/pl.json) Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json) Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json) +Thai¹ | ✓ [fully translated](../src/SMAPI/i18n/th.json) Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json) + +¹ This is a custom language provided by a mod (see [Polish](https://www.nexusmods.com/stardewvalley/mods/3616) +and [Thai](https://www.nexusmods.com/stardewvalley/mods/7052)). diff --git a/docs/release-notes.md b/docs/release-notes.md index 8ed155bd..25b0d740 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,23 @@ ← [README](README.md) # Release notes +## 3.12.8 +Released 18 October 2021 for Stardew Valley 1.5.4. + +* For players: + * Fixed mod edits to the farmhouse shifting the player down one tile in some cases. + * Improved translations. Thanks to ellipszist (added Thai) and Zangorr (added Polish)! + _These are custom languages which require Stardew Valley 1.5.5 and the [Polish](https://www.nexusmods.com/stardewvalley/mods/3616) or [Thai](https://www.nexusmods.com/stardewvalley/mods/7052) mod._ + +* For mod authors: + * SMAPI now intercepts dictionary duplicate-key errors and adds the key to the error message to simplify troubleshooting. (Due to Harmony limitations, this only works for the dictionary types used by the game.) + * Fixed map tile rotations/flips not working for farmhands in split-screen mode. + * Fixed barn/coop exit warps being reset when you edit their interior map. + +* For the web UI: + * Added support for unified [mod data overrides](https://stardewvalleywiki.com/Modding:Mod_compatibility#Mod_data_overrides) defined on the wiki. + * The mod compatibility list now shows separate beta stats when 'show advanced info' is enabled. + ## 3.12.7 Released 18 September 2021 for Stardew Valley 1.5.4. diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md index 4be062e2..f454e99c 100644 --- a/docs/technical/smapi.md +++ b/docs/technical/smapi.md @@ -76,10 +76,48 @@ the debugger attached, so you can intercept errors and step through the code bei doesn't work in MonoDevelop on Linux, unfortunately. ### Preparing a release -To prepare a crossplatform SMAPI release, you'll need to compile it on two platforms. See -[crossplatforming info](https://stardewvalleywiki.com/Modding:Modder_Guide/Test_and_Troubleshoot#Testing_on_all_platforms) -on the wiki for the first-time setup. - +To prepare a crossplatform SMAPI release, you'll need to compile it on two platforms: Windows and +Linux. The instructions below assume you have Windows 11, but you can adapt them for +a different setup if needed. + +#### Initial setup +First-time setup on Windows: +1. [Install Windows Subsystem for Linux (WSL)](https://docs.microsoft.com/en-us/windows/wsl/install). +2. Install the needed software in WSL: + 1. Run `sudo apt update` to update the package list. + 2. Install [the .NET 5 SDK](https://docs.microsoft.com/en-us/dotnet/core/install/linux-ubuntu) + (for Stardew Valley 1.5.5+) or [`mono-complete`](https://www.mono-project.com/download/stable/) + (for earlier versions). + _You can run `lsb_release -a` to get the Ubuntu version number._ + 3. [Install Steam](https://linuxconfig.org/how-to-install-steam-on-ubuntu-20-04-focal-fossa-linux). + 4. Launch `steam` and install the game like usual. + 5. Download and install your preferred IDE. For the [latest standalone Rider + version](https://www.jetbrains.com/help/rider/Installation_guide.html#prerequisites): + ```sh + wget "<download url here>" -O rider-install.tar.gz + sudo tar -xzvf rider-install.tar.gz -C /opt + ln -s "/opt/JetBrains Rider-<version>/bin/rider.sh" + ./rider.sh + ``` + 3. Clone the SMAPI repo in WSL: + ```sh + git clone https://github.com/Pathoschild/SMAPI.git + ``` + +To compile SMAPI in WSL: +1. Run `./rider.sh` to open the Rider GUI. +2. Use the GUI to compile the solution. + +To launch the game: +1. Open a WSL terminal. +2. Run these commands to start Steam: + ```sh + export TERM=xterm + steam + ``` +3. Launch the game through the Steam UI. + +#### Prepare the release 1. Update the version numbers in `build/common.targets`, `Constants`, and the `manifest.json` for bundled mods. Make sure you use a [semantic version](https://semver.org). Recommended format: @@ -90,8 +128,8 @@ on the wiki for the first-time setup. release | `<version>` | `3.0.0` 2. In Windows: 1. Rebuild the solution with the _release_ solution configuration. - 2. Copy the `bin/SMAPI installer` and `bin/SMAPI installer for developers` folders to Linux/macOS. -4. In Linux/macOS: + 2. Copy the `bin/SMAPI installer` and `bin/SMAPI installer for developers` folders to Linux. +4. In Linux: 1. Rebuild the solution with the _release_ solution configuration. 2. Add the `windows-install.*` files from Windows to the `bin/SMAPI installer` and `bin/SMAPI installer for developers` folders compiled on Linux. diff --git a/src/SMAPI.Installer/SMAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index 1777be5f..c47f3e6e 100644 --- a/src/SMAPI.Installer/SMAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <RootNamespace>StardewModdingAPI.Installer</RootNamespace> <Description>The SMAPI installer for players.</Description> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <OutputType>Exe</OutputType> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> diff --git a/src/SMAPI.Internal/ExceptionHelper.cs b/src/SMAPI.Internal/ExceptionHelper.cs index 4bc55f95..05b96c2e 100644 --- a/src/SMAPI.Internal/ExceptionHelper.cs +++ b/src/SMAPI.Internal/ExceptionHelper.cs @@ -65,7 +65,7 @@ namespace StardewModdingAPI.Internal // remove unneeded root build paths for SMAPI and Stardew Valley message = message - .Replace(@"C:\source\_Stardew\SMAPI\src\", "") + .Replace(@"E:\source\_Stardew\SMAPI\src\", "") .Replace(@"C:\GitlabRunner\builds\Gq5qA5P4\0\ConcernedApe\", ""); // remove placeholder info in Linux/macOS stack traces diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index b18e79d5..93769650 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <!--build--> <RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <LangVersion>latest</LangVersion> <GeneratePackageOnBuild>true</GeneratePackageOnBuild> diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj index a187c1ff..528348a0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <AssemblyName>ConsoleCommands</AssemblyName> <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index e53bf991..9af3ce5d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.12.7", + "Version": "3.12.8", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.12.7" + "MinimumApiVersion": "3.12.8" } diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs index 6ad64e16..8ceafcc5 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs @@ -48,6 +48,11 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches original: AccessTools.Method(dictionaryType, "get_Item") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "get_Item")} to patch."), finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_GetItem)) ); + + harmony.Patch( + original: AccessTools.Method(dictionaryType, "Add") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "Add")} to patch."), + finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_Add)) + ); } } } @@ -63,13 +68,31 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches private static Exception Finalize_GetItem(object key, Exception __exception) { if (__exception is KeyNotFoundException) - { - DictionaryPatcher.Reflection - .GetField<string>(__exception, "_message") - .SetValue($"{__exception.Message}\nkey: '{key}'"); - } + DictionaryPatcher.AddKey(__exception, key); return __exception; } + + /// <summary>The method to call after a dictionary insert throws an exception.</summary> + /// <param name="key">The dictionary key being inserted.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Add(object key, Exception __exception) + { + if (__exception is ArgumentException) + DictionaryPatcher.AddKey(__exception, key); + + return __exception; + } + + /// <summary>Add the dictionary key to an exception message.</summary> + /// <param name="exception">The exception whose message to edit.</param> + /// <param name="key">The dictionary key.</param> + private static void AddKey(Exception exception, object key) + { + DictionaryPatcher.Reflection + .GetField<string>(exception, "_message") + .SetValue($"{exception.Message}\nkey: '{key}'"); + } } } diff --git a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj index eb878bc5..182a978e 100644 --- a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj +++ b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <AssemblyName>ErrorHandler</AssemblyName> <RootNamespace>StardewModdingAPI.Mods.ErrorHandler</RootNamespace> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/pl.json b/src/SMAPI.Mods.ErrorHandler/i18n/pl.json new file mode 100644 index 00000000..05a9d411 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/pl.json @@ -0,0 +1,4 @@ +{
+ // warning messages
+ "warn.invalid-content-removed": "Nieprawidłowa zawartość została usunięta, aby zapobiec awarii (zobacz konsolę SMAPI po więcej informacji)."
+}
diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/th.json b/src/SMAPI.Mods.ErrorHandler/i18n/th.json new file mode 100644 index 00000000..e2a67dda --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/th.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "ทำการลบเนื้อหาที่ไม่ถูกต้องออก เพื่อป้องกันไฟล์เกมเสียหาย (ดูรายละเอียดที่หน้าคอลโซลของ SMAPI)" +} diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 37a7e9bf..cfbaa661 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.12.7", + "Version": "3.12.8", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.12.7" + "MinimumApiVersion": "3.12.8" } diff --git a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj index a6f76781..079beb08 100644 --- a/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <AssemblyName>SaveBackup</AssemblyName> <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 28c7ec14..1efd1d89 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.12.7", + "Version": "3.12.8", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.12.7" + "MinimumApiVersion": "3.12.8" } diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 27520baf..8f7bfab4 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <AssemblyName>SMAPI.Tests</AssemblyName> <RootNamespace>SMAPI.Tests</RootNamespace> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> </PropertyGroup> diff --git a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs new file mode 100644 index 00000000..b896b09c --- /dev/null +++ b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace SMAPI.Tests.WikiClient +{ + /// <summary>Unit tests for <see cref="ChangeDescriptor"/>.</summary> + [TestFixture] + internal class ChangeDescriptorTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that Parse sets the expected values for valid and invalid descriptors.")] + public void Parse_SetsExpectedValues_Raw() + { + // arrange + string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB"; + string[] expectedAdd = new[] { "Nexus:451", "A" }; + string[] expectedRemove = new[] { "Nexus:2400", "B" }; + IDictionary<string, string> expectedReplace = new Dictionary<string, string> + { + ["XX"] = "YY", + ["XXX"] = "YYY" + }; + string[] expectedErrors = new[] + { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse(rawDescriptor, out string[] errors); + + // assert + Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); + Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); + } + + [Test(Description = "Assert that Parse sets the expected values for descriptors when a format callback is specified.")] + public void Parse_SetsExpectedValues_Formatted() + { + // arrange + string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB"; + string[] expectedAdd = new[] { "1.0.0", "2.0.0-beta.15" }; + string[] expectedRemove = new[] { "1.0.1", "2.0.0-beta" }; + IDictionary<string, string> expectedReplace = new Dictionary<string, string> + { + ["1.00"] = "1.0.0", + ["2.0.0"] = "2.0.0-beta" + }; + string[] expectedErrors = new[] + { + "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", + "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." + }; + + // act + ChangeDescriptor parsed = ChangeDescriptor.Parse( + rawDescriptor, + out string[] errors, + formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) + ? version.ToString() + : raw + ); + + // assert + Assert.That(parsed.Add, Is.EquivalentTo(expectedAdd), $"{nameof(parsed.Add)} doesn't match the expected value."); + Assert.That(parsed.Remove, Is.EquivalentTo(expectedRemove), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(parsed.Replace, Is.EquivalentTo(expectedReplace), $"{nameof(parsed.Replace)} doesn't match the expected value."); + Assert.That(errors, Is.EquivalentTo(expectedErrors), $"{nameof(errors)} doesn't match the expected value."); + } + + [Test(Description = "Assert that Apply returns the expected value for the given descriptor.")] + + // null input + [TestCase(null, "", ExpectedResult = null)] + [TestCase(null, "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase(null, "-Nexus:2400", ExpectedResult = null)] + + // blank input + [TestCase("", null, ExpectedResult = "")] + [TestCase("", "", ExpectedResult = "")] + + // add value + [TestCase("", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "Nexus:2400", ExpectedResult = "Nexus:2400")] + [TestCase("Nexus:2400", "+Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + [TestCase("Nexus:2400", "Nexus:2401", ExpectedResult = "Nexus:2400, Nexus:2401")] + + // remove value + [TestCase("", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2400", ExpectedResult = "")] + [TestCase("Nexus:2400", "-Nexus:2401", ExpectedResult = "Nexus:2400")] + + // replace value + [TestCase("", "Nexus:2400 → Nexus:2401", ExpectedResult = "")] + [TestCase("Nexus:2400", "Nexus:2400 → Nexus:2401", ExpectedResult = "Nexus:2401")] + [TestCase("Nexus:1", "Nexus: 2400 → Nexus: 2401", ExpectedResult = "Nexus:1")] + + // complex strings + [TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")] + [TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")] + [TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")] + public string Apply_Raw(string input, string descriptor) + { + var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + + Assert.IsEmpty(errors, "Parsing the descriptor failed."); + + return parsed.ApplyToCopy(input); + } + + [Test(Description = "Assert that ToString returns the expected normalized descriptors.")] + [TestCase(null, ExpectedResult = "")] + [TestCase("", ExpectedResult = "")] + [TestCase("+ Nexus:2400", ExpectedResult = "+Nexus:2400")] + [TestCase(" Nexus:2400 ", ExpectedResult = "+Nexus:2400")] + [TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")] + [TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")] + [TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")] + public string ToString(string descriptor) + { + var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + + Assert.IsEmpty(errors, "Parsing the descriptor failed."); + + return parsed.ToString(); + } + } +} diff --git a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index d36a1882..0e1e40b2 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <RootNamespace>StardewModdingAPI</RootNamespace> <Description>Provides toolkit interfaces which are available to SMAPI mods.</Description> - <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> + <TargetFrameworks>net452;netstandard2.0</TargetFrameworks> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 8c21e4e0..5c2ce366 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -87,11 +87,14 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /**** ** Version mappings ****/ - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } + /// <summary>A serialized change descriptor to apply to the local version during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeLocalVersions { get; set; } - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } + /// <summary>A serialized change descriptor to apply to the remote version during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeRemoteVersions { get; set; } + + /// <summary>A serialized change descriptor to apply to the update keys during update checks (see <see cref="ChangeDescriptor"/>).</summary> + public string ChangeUpdateKeys { get; set; } /********* @@ -137,8 +140,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn; - this.MapLocalVersions = wiki.MapLocalVersions; - this.MapRemoteVersions = wiki.MapRemoteVersions; + this.ChangeLocalVersions = wiki.Overrides?.ChangeLocalVersions?.ToString(); + this.ChangeRemoteVersions = wiki.Overrides?.ChangeRemoteVersions?.ToString(); + this.ChangeUpdateKeys = wiki.Overrides?.ChangeUpdateKeys?.ToString(); } // internal DB data diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs new file mode 100644 index 00000000..f1feb44b --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>A set of changes which can be applied to a mod data field.</summary> + public class ChangeDescriptor + { + /********* + ** Accessors + *********/ + /// <summary>The values to add to the field.</summary> + public ISet<string> Add { get; } + + /// <summary>The values to remove from the field.</summary> + public ISet<string> Remove { get; } + + /// <summary>The values to replace in the field, if matched.</summary> + public IReadOnlyDictionary<string, string> Replace { get; } + + /// <summary>Whether the change descriptor would make any changes.</summary> + public bool HasChanges { get; } + + /// <summary>Format a raw value into a normalized form.</summary> + public Func<string, string> FormatValue { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="add">The values to add to the field.</param> + /// <param name="remove">The values to remove from the field.</param> + /// <param name="replace">The values to replace in the field, if matched.</param> + /// <param name="formatValue">Format a raw value into a normalized form.</param> + public ChangeDescriptor(ISet<string> add, ISet<string> remove, IReadOnlyDictionary<string, string> replace, Func<string, string> formatValue) + { + this.Add = add; + this.Remove = remove; + this.Replace = replace; + this.HasChanges = add.Any() || remove.Any() || replace.Any(); + this.FormatValue = formatValue; + } + + /// <summary>Apply the change descriptors to a comma-delimited field.</summary> + /// <param name="rawField">The raw field text.</param> + /// <returns>Returns the modified field.</returns> + public string ApplyToCopy(string rawField) + { + // get list + List<string> values = !string.IsNullOrWhiteSpace(rawField) + ? new List<string>(rawField.Split(',')) + : new List<string>(); + + // apply changes + this.Apply(values); + + // format + if (rawField == null && !values.Any()) + return null; + return string.Join(", ", values); + } + + /// <summary>Apply the change descriptors to the given field values.</summary> + /// <param name="values">The field values.</param> + /// <returns>Returns the modified field values.</returns> + public void Apply(List<string> values) + { + // replace/remove values + if (this.Replace.Any() || this.Remove.Any()) + { + for (int i = values.Count - 1; i >= 0; i--) + { + string value = this.FormatValue(values[i]?.Trim() ?? string.Empty); + + if (this.Remove.Contains(value)) + values.RemoveAt(i); + + else if (this.Replace.TryGetValue(value, out string newValue)) + values[i] = newValue; + } + } + + // add values + if (this.Add.Any()) + { + HashSet<string> curValues = new HashSet<string>(values.Select(p => p?.Trim() ?? string.Empty), StringComparer.OrdinalIgnoreCase); + foreach (string add in this.Add) + { + if (!curValues.Contains(add)) + { + values.Add(add); + curValues.Add(add); + } + } + } + } + + /// <inheritdoc /> + public override string ToString() + { + if (!this.HasChanges) + return string.Empty; + + List<string> descriptors = new List<string>(this.Add.Count + this.Remove.Count + this.Replace.Count); + foreach (string add in this.Add) + descriptors.Add($"+{add}"); + foreach (string remove in this.Remove) + descriptors.Add($"-{remove}"); + foreach (var pair in this.Replace) + descriptors.Add($"{pair.Key} → {pair.Value}"); + + return string.Join(", ", descriptors); + } + + /// <summary>Parse a raw change descriptor string into a <see cref="ChangeDescriptor"/> model.</summary> + /// <param name="descriptor">The raw change descriptor.</param> + /// <param name="errors">The human-readable error message describing any invalid values that were ignored.</param> + /// <param name="formatValue">Format a raw value into a normalized form if needed.</param> + public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func<string, string> formatValue = null) + { + // init + formatValue ??= p => p; + var add = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var remove = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + var replace = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // parse each change in the descriptor + if (!string.IsNullOrWhiteSpace(descriptor)) + { + List<string> rawErrors = new List<string>(); + foreach (string rawEntry in descriptor.Split(',')) + { + // normalize entry + string entry = rawEntry.Trim(); + if (entry == string.Empty) + continue; + + // parse as replace (old value → new value) + if (entry.Contains('→')) + { + string[] parts = entry.Split(new[] { '→' }, 2); + string oldValue = formatValue(parts[0].Trim()); + string newValue = formatValue(parts[1].Trim()); + + if (oldValue == string.Empty) + { + rawErrors.Add($"Failed parsing '{rawEntry}': can't map from a blank old value. Use the '+value' format to add a value."); + continue; + } + + if (newValue == string.Empty) + { + rawErrors.Add($"Failed parsing '{rawEntry}': can't map to a blank value. Use the '-value' format to remove a value."); + continue; + } + + replace[oldValue] = newValue; + } + + // else as remove + else if (entry.StartsWith("-")) + { + entry = formatValue(entry.Substring(1).Trim()); + remove.Add(entry); + } + + // else as add + else + { + if (entry.StartsWith("+")) + entry = formatValue(entry.Substring(1).Trim()); + add.Add(entry); + } + } + + errors = rawErrors.ToArray(); + } + else + errors = new string[0]; + + // build model + return new ChangeDescriptor( + add: add, + remove: remove, + replace: new ReadOnlyDictionary<string, string>(replace), + formatValue: formatValue + ); + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index da312471..f85e82e1 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -6,6 +6,7 @@ using System.Net; using System.Threading.Tasks; using HtmlAgilityPack; using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { @@ -55,13 +56,33 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki if (betaVersion == stableVersion) betaVersion = null; - // find mod entries - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + // parse mod data overrides + Dictionary<string, WikiDataOverrideEntry> overrides = new Dictionary<string, WikiDataOverrideEntry>(StringComparer.OrdinalIgnoreCase); + { + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-overrides-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); + + foreach (var entry in this.ParseOverrideEntries(modNodes)) + { + if (entry.Ids?.Any() != true || !entry.HasChanges) + continue; + + foreach (string id in entry.Ids) + overrides[id] = entry; + } + } + + // parse mod entries + WikiModEntry[] mods; + { + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("//table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + mods = this.ParseModEntries(modNodes, overrides).ToArray(); + } - // parse - WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray(); + // build model return new WikiModList { StableVersion = stableVersion, @@ -82,7 +103,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki *********/ /// <summary>Parse valid mod compatibility entries.</summary> /// <param name="nodes">The HTML compatibility entries.</param> - private IEnumerable<WikiModEntry> ParseEntries(IEnumerable<HtmlNode> nodes) + /// <param name="overridesById">The mod data overrides to apply, if any.</param> + private IEnumerable<WikiModEntry> ParseModEntries(IEnumerable<HtmlNode> nodes, IDictionary<string, WikiDataOverrideEntry> overridesById) { foreach (HtmlNode node in nodes) { @@ -103,9 +125,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); string devNote = this.GetAttribute(node, "data-dev-note"); string pullRequestUrl = this.GetAttribute(node, "data-pr"); - IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); - IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); - string[] changeUpdateKeys = this.GetAttributeAsCsv(node, "data-change-update-keys"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo @@ -134,6 +153,11 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } } + // find data overrides + WikiDataOverrideEntry overrides = ids + .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) + .FirstOrDefault(p => p != null); + // yield model yield return new WikiModEntry { @@ -154,14 +178,35 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Warnings = warnings, PullRequestUrl = pullRequestUrl, DevNote = devNote, - ChangeUpdateKeys = changeUpdateKeys, - MapLocalVersions = mapLocalVersions, - MapRemoteVersions = mapRemoteVersions, + Overrides = overrides, Anchor = anchor }; } } + /// <summary>Parse valid mod data override entries.</summary> + /// <param name="nodes">The HTML mod data override entries.</param> + private IEnumerable<WikiDataOverrideEntry> ParseOverrideEntries(IEnumerable<HtmlNode> nodes) + { + foreach (HtmlNode node in nodes) + { + yield return new WikiDataOverrideEntry + { + Ids = this.GetAttributeAsCsv(node, "data-id"), + ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", + raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + ), + ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", + raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + ), + + ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", + raw => UpdateKey.TryParse(raw, out UpdateKey key) ? key.ToString() : raw + ) + }; + } + } + /// <summary>Get an attribute value.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> @@ -174,6 +219,18 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki return WebUtility.HtmlDecode(value); } + /// <summary>Get an attribute value and parse it as a change descriptor.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + /// <param name="formatValue">Format an raw entry value when applying changes.</param> + private ChangeDescriptor GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func<string, string> formatValue) + { + string raw = this.GetAttribute(element, name); + return raw != null + ? ChangeDescriptor.Parse(raw, out _, formatValue) + : null; + } + /// <summary>Get an attribute value and parse it as a comma-delimited list of strings.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> @@ -221,28 +278,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki return null; } - /// <summary>Get an attribute value and parse it as a version mapping.</summary> - /// <param name="element">The element whose attributes to read.</param> - /// <param name="name">The attribute name.</param> - private IDictionary<string, string> GetAttributeAsVersionMapping(HtmlNode element, string name) - { - // get raw value - string raw = this.GetAttribute(element, name); - if (raw?.Contains("→") != true) - return null; - - // parse - // Specified on the wiki in the form "remote version → mapped version; another remote version → mapped version" - IDictionary<string, string> map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - foreach (string pair in raw.Split(';')) - { - string[] versions = pair.Split('→'); - if (versions.Length == 2 && !string.IsNullOrWhiteSpace(versions[0]) && !string.IsNullOrWhiteSpace(versions[1])) - map[versions[0].Trim()] = versions[1].Trim(); - } - return map; - } - /// <summary>Get the text of an element with the given class name.</summary> /// <param name="container">The metadata container.</param> /// <param name="className">The field name.</param> diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs new file mode 100644 index 00000000..0587e09d --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs @@ -0,0 +1,29 @@ +#nullable enable + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>The data overrides to apply to matching mods.</summary> + public class WikiDataOverrideEntry + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod IDs for the mods to override.</summary> + public string[] Ids { get; set; } = new string[0]; + + /// <summary>Maps local versions to a semantic version for update checks.</summary> + public ChangeDescriptor? ChangeLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + public ChangeDescriptor? ChangeRemoteVersions { get; set; } + + /// <summary>Update keys to add (optionally prefixed by '+'), remove (prefixed by '-'), or replace.</summary> + public ChangeDescriptor? ChangeUpdateKeys { get; set; } + + /// <summary>Whether the entry has any changes.</summary> + public bool HasChanges => + this.ChangeLocalVersions?.HasChanges == true + || this.ChangeRemoteVersions?.HasChanges == true + || this.ChangeUpdateKeys?.HasChanges == true; + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 21466c6a..4e0104da 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,6 +1,3 @@ -using System; -using System.Collections.Generic; - namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// <summary>A mod entry in the wiki list.</summary> @@ -63,14 +60,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> public string DevNote { get; set; } - /// <summary>Update keys to add (optionally prefixed by '+') or remove (prefixed by '-').</summary> - public string[] ChangeUpdateKeys { get; set; } - - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } - - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } + /// <summary>The data overrides to apply to the mod's manifest or remote mod page data, if any.</summary> + public WikiDataOverrideEntry Overrides { get; set; } /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> public string Anchor { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 7e4d0220..077c0361 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -89,6 +89,16 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData return new UpdateKey(raw, site, id, subkey); } + /// <summary>Parse a raw update key if it's valid.</summary> + /// <param name="raw">The raw update key to parse.</param> + /// <param name="parsed">The parsed update key, if valid.</param> + /// <returns>Returns whether the update key was successfully parsed.</returns> + public static bool TryParse(string raw, out UpdateKey parsed) + { + parsed = UpdateKey.Parse(raw); + return parsed.LooksValid; + } + /// <summary>Get a string that represents the current object.</summary> public override string ToString() { diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj index 0e051c84..3d87c169 100644 --- a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -2,7 +2,7 @@ <PropertyGroup> <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> <Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description> - <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> + <TargetFrameworks>net452;netstandard2.0</TargetFrameworks> <GenerateDocumentationFile>true</GenerateDocumentationFile> </PropertyGroup> diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index c6e9a713..dcddaf10 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -144,7 +144,7 @@ namespace StardewModdingAPI.Web.Controllers } // fetch data - ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.MapRemoteVersions); + ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey, allowNonStandardVersions, wikiEntry?.Overrides?.ChangeRemoteVersions); if (data.Status != RemoteModStatus.Ok) { errors.Add(data.Error ?? data.Status.ToString()); @@ -195,7 +195,7 @@ namespace StardewModdingAPI.Web.Controllers } // get recommended update (if any) - ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -255,8 +255,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) { // get mod page IModPage page; @@ -290,15 +290,12 @@ namespace StardewModdingAPI.Web.Controllers .Distinct() .ToList(); - // apply remove overrides from wiki + // apply overrides from wiki + if (entry?.Overrides?.ChangeUpdateKeys?.HasChanges == true) { - var removeKeys = new HashSet<UpdateKey>( - from key in entry?.ChangeUpdateKeys ?? new string[0] - where key.StartsWith('-') - select UpdateKey.Parse(key.Substring(1)) - ); - if (removeKeys.Any()) - updateKeys.RemoveAll(removeKeys.Contains); + List<string> newKeys = updateKeys.Select(p => p.ToString()).ToList(); + entry.Overrides.ChangeUpdateKeys.Apply(newKeys); + updateKeys = newKeys.Select(UpdateKey.Parse).ToList(); } // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority @@ -348,15 +345,6 @@ namespace StardewModdingAPI.Web.Controllers if (entry.ChucklefishID.HasValue) yield return UpdateKey.GetString(ModSiteKey.Chucklefish, entry.ChucklefishID.ToString()); } - - // overrides from wiki - foreach (string key in entry?.ChangeUpdateKeys ?? Array.Empty<string>()) - { - if (key.StartsWith('+')) - yield return key.Substring(1); - else if (!key.StartsWith("-")) - yield return key; - } } } } diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index 8f21d2f5..a2b92aa4 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients; @@ -55,9 +56,9 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Parse version info for the given mod page info.</summary> /// <param name="page">The mod page info.</param> /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) { // get base model ModInfoModel model = new ModInfoModel() @@ -79,9 +80,9 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to parse.</param> - /// <param name="map">A map of version replacements.</param> + /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - public ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + public ISemanticVersion GetMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) { // try mapped version string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); @@ -102,10 +103,10 @@ namespace StardewModdingAPI.Web.Framework /// <param name="mod">The mod to check.</param> /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - /// <param name="mapRemoteVersions">Maps remote versions to a semantic version for update checks.</param> + /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="main">The main mod version.</param> /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> - private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, IDictionary<string, string> mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) { main = null; preview = null; @@ -179,31 +180,17 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to map.</param> - /// <param name="map">A map of version replacements.</param> + /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private string GetRawMappedVersion(string version, IDictionary<string, string> map, bool allowNonStandard) + private string GetRawMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) { - if (version == null || map == null || !map.Any()) + if (version == null || map?.HasChanges != true) return version; - // match exact raw version - if (map.ContainsKey(version)) - return map[version]; + var mapped = new List<string> { version }; + map.Apply(mapped); - // match parsed version - if (SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsed)) - { - if (map.ContainsKey(parsed.ToString())) - return map[parsed.ToString()]; - - foreach ((string fromRaw, string toRaw) in map) - { - if (SemanticVersion.TryParse(fromRaw, allowNonStandard, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(toRaw, allowNonStandard, out ISemanticVersion newVersion)) - return newVersion.ToString(); - } - } - - return version; + return mapped.FirstOrDefault(); } /// <summary>Normalize a version string.</summary> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 2556936c..d8561172 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -205,15 +205,21 @@ namespace StardewModdingAPI.Web // shortcut paths .Add(new RedirectPathsToUrlsRule(new Dictionary<string, string> { + // wiki pages [@"^/3\.0\.?$"] = "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0", - [@"^/(?:buildmsg|package)(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", // buildmsg deprecated, remove when SDV 1.4 is released [@"^/community\.?$"] = "https://stardewvalleywiki.com/Modding:Community", - [@"^/compat\.?$"] = "https://smapi.io/mods", [@"^/docs\.?$"] = "https://stardewvalleywiki.com/Modding:Index", [@"^/help\.?$"] = "https://stardewvalleywiki.com/Modding:Help", [@"^/install\.?$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI", [@"^/troubleshoot(.*)$"] = "https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting$1", - [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods" + [@"^/xnb\.?$"] = "https://stardewvalleywiki.com/Modding:Using_XNB_mods", + + // GitHub docs + [@"^/package(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1", + [@"^/release(?:/?(.*))$"] = "https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#$1", + + // legacy redirects + [@"^/compat\.?$"] = "https://smapi.io/mods" })) // legacy paths diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 5df49afb..8a764803 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -16,7 +16,7 @@ <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.3" crossorigin="anonymous"></script> - <script src="~/Content/js/mods.js?r=20200218"></script> + <script src="~/Content/js/mods.js?r=20210929"></script> <script> $(function() { var data = @this.ForJson(Model.Mods ?? new ModModel[0]); @@ -65,9 +65,15 @@ else </div> </div> <div id="mod-count" v-show="showAdvanced"> - <div v-if="visibleStats.total > 0"> - {{visibleStats.total}} mods shown ({{Math.round((visibleStats.compatible + visibleStats.workaround) / visibleStats.total * 100)}}% compatible or have a workaround, {{Math.round((visibleStats.soon + visibleStats.broken) / visibleStats.total * 100)}}% broken, {{Math.round(visibleStats.abandoned / visibleStats.total * 100)}}% obsolete). + <div v-if="visibleMainStats.total > 0"> + {{visibleMainStats.total}} mods shown ({{visibleMainStats.percentCompatible}}% compatible or have a workaround, {{visibleMainStats.percentBroken}}% broken, {{visibleMainStats.percentObsolete}}% obsolete). </div> + @if (hasBeta) + { + <div v-if="visibleBetaStats.total > 0"> + <strong>@betaLabel</strong>: {{visibleBetaStats.total}} mods shown ({{visibleBetaStats.percentCompatible}}% compatible or have a workaround, {{visibleBetaStats.percentBroken}}% broken, {{visibleBetaStats.percentObsolete}}% obsolete). + </div> + } <span v-else>No matching mods found.</span> </div> <table class="wikitable" id="mod-list"> diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index ac2754a4..945f93ef 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -9,12 +9,16 @@ smapi.modList = function (mods, enableBeta) { soon: 0, broken: 0, abandoned: 0, - invalid: 0 + invalid: 0, + percentCompatible: 0, + percentBroken: 0, + percentObsolete: 0 }; var data = { mods: mods, showAdvanced: false, - visibleStats: $.extend({}, defaultStats), + visibleMainStats: $.extend({}, defaultStats), + visibleBetaStats: $.extend({}, defaultStats), filters: { source: { value: { @@ -124,7 +128,8 @@ smapi.modList = function (mods, enableBeta) { var words = data.search.toLowerCase().split(" "); // apply criteria - var stats = data.visibleStats = $.extend({}, defaultStats); + var mainStats = data.visibleMainStats = $.extend({}, defaultStats); + var betaStats = data.visibleBetaStats = $.extend({}, defaultStats); for (var i = 0; i < data.mods.length; i++) { var mod = data.mods[i]; mod.Visible = true; @@ -132,10 +137,20 @@ smapi.modList = function (mods, enableBeta) { // check filters mod.Visible = this.matchesFilters(mod, words); if (mod.Visible) { - stats.total++; - stats[this.getCompatibilityGroup(mod)]++; + mainStats.total++; + betaStats.total++; + + mainStats[this.getCompatibilityGroup(mod.Compatibility.Status)]++; + betaStats[this.getCompatibilityGroup(mod.LatestCompatibility.Status)]++; } } + + // add aggregate stats + for (let stats of [mainStats, betaStats]) { + stats.percentCompatible = Math.round((stats.compatible + stats.workaround) / stats.total * 100); + stats.percentBroken = Math.round((stats.soon + stats.broken) / stats.total * 100); + stats.percentObsolete = Math.round(stats.abandoned / stats.total * 100); + } }, /** @@ -220,11 +235,10 @@ smapi.modList = function (mods, enableBeta) { /** * Get a mod's compatibility group for mod metrics. - * @param {object} mod The mod to check. + * @param {string} mod The mod status for which to get the group. * @returns {string} The compatibility group (one of 'compatible', 'workaround', 'soon', 'broken', 'abandoned', or 'invalid'). */ - getCompatibilityGroup: function (mod) { - var status = mod.LatestCompatibility.Status; + getCompatibilityGroup: function (status) { switch (status) { // obsolete case "abandoned": diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index fbc00d1d..42c3b21b 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -54,7 +54,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.12.7"; + internal static string RawApiVersion = "3.12.8"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 86b69239..6dffb1de 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -247,7 +247,7 @@ namespace StardewModdingAPI.Framework multiplayer: this.Multiplayer, exitGameImmediately: this.ExitGameImmediately, - onGameContentLoaded: this.OnGameContentLoaded, + onGameContentLoaded: this.OnInstanceContentLoaded, onGameUpdating: this.OnGameUpdating, onPlayerInstanceUpdating: this.OnPlayerInstanceUpdating, onGameExiting: this.OnGameExiting @@ -429,8 +429,8 @@ namespace StardewModdingAPI.Framework ).Start(); } - /// <summary>Raised after the game finishes loading its initial content.</summary> - private void OnGameContentLoaded() + /// <summary>Raised after an instance finishes loading its initial content.</summary> + private void OnInstanceContentLoaded() { // override map display device Game1.mapDisplayDevice = new SDisplayDevice(Game1.content, Game1.game1.GraphicsDevice); diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 55ab8377..4e134455 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -54,6 +54,9 @@ namespace StardewModdingAPI.Framework /// <summary>Raised when the instance is updating its state (roughly 60 times per second).</summary> private readonly Action<SGame, GameTime, Action> OnUpdating; + /// <summary>Raised after the instance finishes loading its initial content.</summary> + private readonly Action OnContentLoaded; + /********* ** Accessors @@ -106,7 +109,8 @@ namespace StardewModdingAPI.Framework /// <param name="multiplayer">The core multiplayer logic.</param> /// <param name="exitGameImmediately">Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</param> /// <param name="onUpdating">Raised when the instance is updating its state (roughly 60 times per second).</param> - public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflector reflection, EventManager eventManager, SInputState input, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action<SGame, GameTime, Action> onUpdating) + /// <param name="onContentLoaded">Raised after the game finishes loading its initial content.</param> + public SGame(PlayerIndex playerIndex, int instanceIndex, Monitor monitor, Reflector reflection, EventManager eventManager, SInputState input, SModHooks modHooks, SMultiplayer multiplayer, Action<string> exitGameImmediately, Action<SGame, GameTime, Action> onUpdating, Action onContentLoaded) : base(playerIndex, instanceIndex) { // init XNA @@ -124,6 +128,7 @@ namespace StardewModdingAPI.Framework this.Reflection = reflection; this.ExitGameImmediately = exitGameImmediately; this.OnUpdating = onUpdating; + this.OnContentLoaded = onContentLoaded; } /// <summary>Get the current input state for a button.</summary> @@ -138,6 +143,13 @@ namespace StardewModdingAPI.Framework return input.GetState(button); } + /// <inheritdoc /> + protected override void LoadContent() + { + base.LoadContent(); + + this.OnContentLoaded(); + } /********* ** Protected methods diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs index 45e7369c..b816bb7c 100644 --- a/src/SMAPI/Framework/SGameRunner.cs +++ b/src/SMAPI/Framework/SGameRunner.cs @@ -94,7 +94,7 @@ namespace StardewModdingAPI.Framework public override Game1 CreateGameInstance(PlayerIndex playerIndex = PlayerIndex.One, int instanceIndex = 0) { SInputState inputState = new SInputState(); - return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating); + return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating, this.OnGameContentLoaded); } /// <inheritdoc /> @@ -129,14 +129,6 @@ namespace StardewModdingAPI.Framework /********* ** Protected methods *********/ - /// <summary>Load content when the game is launched.</summary> - protected override void LoadContent() - { - base.LoadContent(); - - this.OnGameContentLoaded(); - } - /// <summary>Perform cleanup logic when the game exits.</summary> /// <param name="sender">The event sender.</param> /// <param name="args">The event args.</param> diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 35ae26b3..7efd99a0 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; +using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; @@ -183,8 +184,10 @@ namespace StardewModdingAPI.Metadata if (!ignoreWorld) { - foreach (GameLocation location in this.GetLocations()) + foreach (LocationInfo info in this.GetLocationsWithInfo()) { + GameLocation location = info.Location; + if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) { static ISet<string> GetWarpSet(GameLocation location) @@ -195,7 +198,7 @@ namespace StardewModdingAPI.Metadata } var oldWarps = GetWarpSet(location); - this.ReloadMap(location); + this.ReloadMap(info); var newWarps = GetWarpSet(location); changedWarps = changedWarps || oldWarps.Count != newWarps.Count || oldWarps.Any(p => !newWarps.Contains(p)); @@ -905,9 +908,12 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the map for a location.</summary> - /// <param name="location">The location whose map to reload.</param> - private void ReloadMap(GameLocation location) + /// <param name="locationInfo">The location whose map to reload.</param> + private void ReloadMap(LocationInfo locationInfo) { + GameLocation location = locationInfo.Location; + Vector2? playerPos = Game1.player?.Position; + if (this.AggressiveMemoryOptimizations) location.map.DisposeTileSheets(Game1.mapDisplayDevice); @@ -926,6 +932,15 @@ namespace StardewModdingAPI.Metadata // update for changes location.updateWarps(); location.updateDoors(); + locationInfo.ParentBuilding?.updateInteriorWarps(); + + // reset player position + // The game may move the player as part of the map changes, even if they're not in that + // location. That's not needed in this case, and it can have weird effects like players + // warping onto the wrong tile (or even off-screen) if a patch changes the farmhouse + // map on location change. + if (playerPos.HasValue) + Game1.player.Position = playerPos.Value; } /// <summary>Reload the disposition data for matching NPCs.</summary> @@ -1202,6 +1217,13 @@ namespace StardewModdingAPI.Metadata /// <param name="buildingInteriors">Whether to also get the interior locations for constructable buildings.</param> private IEnumerable<GameLocation> GetLocations(bool buildingInteriors = true) { + return this.GetLocationsWithInfo(buildingInteriors).Select(info => info.Location); + } + + /// <summary>Get all locations in the game.</summary> + /// <param name="buildingInteriors">Whether to also get the interior locations for constructable buildings.</param> + private IEnumerable<LocationInfo> GetLocationsWithInfo(bool buildingInteriors = true) + { // get available root locations IEnumerable<GameLocation> rootLocations = Game1.locations; if (SaveGame.loaded?.locations != null) @@ -1210,7 +1232,7 @@ namespace StardewModdingAPI.Metadata // yield root + child locations foreach (GameLocation location in rootLocations) { - yield return location; + yield return new LocationInfo(location, null); if (buildingInteriors && location is BuildableGameLocation buildableLocation) { @@ -1218,7 +1240,7 @@ namespace StardewModdingAPI.Metadata { GameLocation indoors = building.indoors.Value; if (indoors != null) - yield return indoors; + yield return new LocationInfo(indoors, building); } } } @@ -1306,5 +1328,31 @@ namespace StardewModdingAPI.Metadata // remove key from cache return BuildingPainter.paintMaskLookup.Remove(key); } + + /// <summary>Metadata about a location used in asset propagation.</summary> + private readonly struct LocationInfo + { + /********* + ** Accessors + *********/ + /// <summary>The location instance.</summary> + public GameLocation Location { get; } + + /// <summary>The building which contains the location, if any.</summary> + public Building ParentBuilding { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="location">The location instance.</param> + /// <param name="parentBuilding">The building which contains the location, if any.</param> + public LocationInfo(GameLocation location, Building parentBuilding) + { + this.Location = location; + this.ParentBuilding = parentBuilding; + } + } } } diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 0f1b0516..c147e7dc 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -3,7 +3,7 @@ <AssemblyName>StardewModdingAPI</AssemblyName> <RootNamespace>StardewModdingAPI</RootNamespace> <Description>The modding API for Stardew Valley.</Description> - <TargetFramework>net45</TargetFramework> + <TargetFramework>net452</TargetFramework> <PlatformTarget>x86</PlatformTarget> <OutputType>Exe</OutputType> <GenerateDocumentationFile>true</GenerateDocumentationFile> diff --git a/src/SMAPI/i18n/pl.json b/src/SMAPI/i18n/pl.json new file mode 100644 index 00000000..b9c788fc --- /dev/null +++ b/src/SMAPI/i18n/pl.json @@ -0,0 +1,6 @@ +{
+ // short date format for SDate
+ // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2)
+ "generic.date": "{{day}} {{seasonLowercase}}",
+ "generic.date-with-year": "{{day}} {{seasonLowercase}} w roku {{year}}"
+}
diff --git a/src/SMAPI/i18n/th.json b/src/SMAPI/i18n/th.json new file mode 100644 index 00000000..361b7aa7 --- /dev/null +++ b/src/SMAPI/i18n/th.json @@ -0,0 +1,6 @@ +{ + // short date format for SDate + // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) + "generic.date": "วันที่ {{day}} {{season}}", + "generic.date-with-year": "วันที่ {{day}} {{season}} ปีที่ {{year}}" +} |