summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2021-10-17 19:53:40 -0400
committerJesse Plamondon-Willard <Pathoschild@users.noreply.github.com>2021-10-17 19:53:40 -0400
commitd578345cfd53df8a91ae8e0e1346b711332a999a (patch)
treefa9a2e6c855a532dbd99910c7cdac1dc468010da /src
parentebe41180c41f544919c03fb3bf6029437a7d65a4 (diff)
parentf8c9a2929bb42ef7f71fa3a2d258c5566960aa69 (diff)
downloadSMAPI-d578345cfd53df8a91ae8e0e1346b711332a999a.tar.gz
SMAPI-d578345cfd53df8a91ae8e0e1346b711332a999a.tar.bz2
SMAPI-d578345cfd53df8a91ae8e0e1346b711332a999a.zip
Merge branch 'develop' into stable
Diffstat (limited to 'src')
-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
35 files changed, 651 insertions, 154 deletions
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());<