summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build/common.targets6
-rw-r--r--build/prepare-install-package.targets2
-rw-r--r--docs/README.md5
-rw-r--r--docs/release-notes.md17
-rw-r--r--docs/technical/smapi.md50
-rw-r--r--src/SMAPI.Installer/SMAPI.Installer.csproj2
-rw-r--r--src/SMAPI.Internal/ExceptionHelper.cs2
-rw-r--r--src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj2
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj2
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs33
-rw-r--r--src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj2
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/pl.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/i18n/th.json4
-rw-r--r--src/SMAPI.Mods.ErrorHandler/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj2
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Tests/SMAPI.Tests.csproj2
-rw-r--r--src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs139
-rw-r--r--src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj2
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs16
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs193
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs105
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs29
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs13
-rw-r--r--src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs10
-rw-r--r--src/SMAPI.Toolkit/SMAPI.Toolkit.csproj2
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs30
-rw-r--r--src/SMAPI.Web/Framework/ModSiteManager.cs39
-rw-r--r--src/SMAPI.Web/Startup.cs12
-rw-r--r--src/SMAPI.Web/Views/Mods/Index.cshtml12
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/mods.js30
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Framework/SCore.cs6
-rw-r--r--src/SMAPI/Framework/SGame.cs14
-rw-r--r--src/SMAPI/Framework/SGameRunner.cs10
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs60
-rw-r--r--src/SMAPI/SMAPI.csproj2
-rw-r--r--src/SMAPI/i18n/pl.json6
-rw-r--r--src/SMAPI/i18n/th.json6
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}}"
+}