summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/CONTRIBUTING.md12
-rw-r--r--.github/SUPPORT.md3
-rw-r--r--docs/release-notes.md32
-rw-r--r--docs/technical/smapi.md13
-rw-r--r--src/SMAPI.Installer/Framework/InstallerPaths.cs18
-rw-r--r--src/SMAPI.Installer/InteractiveInstaller.cs21
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs14
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs59
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs15
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs13
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs15
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs20
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs15
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/ModEntry.cs31
-rw-r--r--src/SMAPI.Tests/Utilities/SemanticVersionTests.cs208
-rw-r--r--src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs3
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs22
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs16
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs12
-rw-r--r--src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs4
-rw-r--r--src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs126
-rw-r--r--src/SMAPI.Toolkit/SemanticVersion.cs72
-rw-r--r--src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs2
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs17
-rw-r--r--src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs8
-rw-r--r--src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs2
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs9
-rw-r--r--src/SMAPI.Web/Framework/VersionConstraint.cs28
-rw-r--r--src/SMAPI.Web/ViewModels/ModModel.cs7
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml4
-rw-r--r--src/SMAPI.Web/Views/Mods/Index.cshtml7
-rw-r--r--src/SMAPI/Constants.cs3
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs1
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs95
-rw-r--r--src/SMAPI/Framework/GameVersion.cs30
-rw-r--r--src/SMAPI/Framework/InternalExtensions.cs70
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs2
-rw-r--r--src/SMAPI/Framework/SCore.cs7
-rw-r--r--src/SMAPI/Framework/SnapshotListDiff.cs6
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs53
-rw-r--r--src/SMAPI/Patches/LoadErrorPatch.cs87
-rw-r--r--src/SMAPI/SMAPI.config.json6
-rw-r--r--src/SMAPI/SemanticVersion.cs34
43 files changed, 895 insertions, 327 deletions
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 8746a487..74a7c500 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -1,16 +1,12 @@
Do you want to...
-* **Ask for help using SMAPI?**
- Please ask in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
- create a GitHub issue.
-
-* **Report a bug?**
- Please report it in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
- create a GitHub issue unless you're sure it's a bug in the SMAPI code.
+* **Ask for help or report a bug?**
+ Please see 'get help' on [the SMAPI website](https://smapi.io) instead, don't create a GitHub
+ issue.
* **Submit a pull request?**
Pull requests are welcome! If you're submitting a new feature, it's best to discuss first to make
- sure it'll be accepted. Feel free to come chat [on Discord or in the SMAPI discussion thread](https://smapi.io/community).
+ sure it'll be accepted. Feel free to come chat [on Discord](https://smapi.io/community).
Documenting your code and using the same formatting conventions is appreciated, but don't worry too
much about it. We'll fix up the code after we accept the pull request if needed.
diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md
index 9263666f..cb968c30 100644
--- a/.github/SUPPORT.md
+++ b/.github/SUPPORT.md
@@ -1,4 +1,3 @@
GitHub issues are only used for SMAPI development tasks.
-To get help with SMAPI problems, [ask on Discord or in the forums](https://smapi.io/community)
-instead.
+To get help with SMAPI problems, see 'get help' on [the SMAPI website](https://smapi.io/) instead.
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 5aa279b5..dc26db2d 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -5,7 +5,28 @@
* For players:
* SMAPI now prevents mods from crashing the game with invalid schedule data.
+ * SMAPI now prevents load crashes due to invalid building types.
+ * Added support for persistent `smapi-internal/config.json` overrides (see info in the file).
* Updated minimum game version (1.4 → 1.4.1).
+ * Fixed 'collection was modified' error when returning to title in rare cases.
+ * Fixed update-check error if a mod's Chucklefish page has no version.
+ * Fixed SMAPI beta versions not showing update alert on next launch (thanks to danvolchek!).
+ * Fixed rare error when building/demolishing buildings.
+
+* For the Console Commands mod:
+ * Added `test_input` command to view button codes in the console.
+
+* For modders:
+ * Asset propagation for player sprites now affects other players' sprites, and updates recolor maps (e.g. sleeves).
+ * Removed invalid-schedule validation which had false positives.
+ * Fixed `helper.Data.Read/WriteGlobalData` using the `Saves` folder. The installer will move existing folders to the appdata folder.
+ * Fixed dialogue asset changes not correctly propagated until the next day.
+
+* For SMAPI/tool developers:
+ * Added internal support for four-part versions to support SMAPI on Android.
+ * Updated links for the new r/SMAPI subreddit.
+ * The `/mods` web API endpoint now includes version mappings from the wiki.
+ * Dropped API support for the pre-3.0 update-check format.
## 3.1
Released 05 January 2019 for Stardew Valley 1.4 or later.
@@ -18,13 +39,14 @@ Released 05 January 2019 for Stardew Valley 1.4 or later.
* Fixed compatibility with Linux Mint 18 (thanks to techge!), Arch Linux, and Linux systems with libhybris-utils installed.
* Fixed memory leak when repeatedly loading a save and returning to title.
* Fixed memory leak when mods reload assets.
- * Fixes for Console Commands mod:
- * added new clothing items;
- * fixed spawning new flooring and rings (thanks to Mizzion!);
- * fixed spawning custom rings added by mods;
- * Fixed errors when some item data is invalid.
* Updated translations. Thanks to L30Bola (added Portuguese), PlussRolf (added Spanish), and shirutan (added Japanese)!
+* For the Console Commands mod:
+ * Added new clothing items.
+ * Fixed spawning new flooring and rings (thanks to Mizzion!).
+ * Fixed spawning custom rings added by mods.
+ * Fixed errors when some item data is invalid.
+
* For the web UI:
* Added option to edit & reupload in the JSON validator.
* File uploads are now stored in Azure storage instead of Pastebin, due to ongoing Pastebin perfomance issues.
diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md
index d565aeb4..c9d5c07e 100644
--- a/docs/technical/smapi.md
+++ b/docs/technical/smapi.md
@@ -19,17 +19,8 @@ This document is about SMAPI itself; see also [mod build package](mod-package.md
## Customisation
### Configuration file
-You can customise the SMAPI behaviour by editing the `smapi-internal/config.json` file in your game
-folder.
-
-Basic fields:
-
-field | purpose
------------------ | -------
-`DeveloperMode` | Default `false` (except in _SMAPI for developers_ releases). Whether to enable features intended for mod developers (mainly more detailed console logging).
-`CheckForUpdates` | Default `true`. Whether SMAPI should check for a newer version when you load the game. If a new version is available, a small message will appear in the console. This doesn't affect the load time even if your connection is offline or slow, because it happens in the background.
-`VerboseLogging` | Default `false`. Whether SMAPI should log more information about the game context.
-`ModData` | Internal metadata about SMAPI mods. Changing this isn't recommended and may destabilise your game. See documentation in the file.
+You can customise some SMAPI behaviour by editing the `smapi-internal/config.json` file in your
+game folder. See documentation in the file for more info.
### Command-line arguments
The SMAPI installer recognises three command-line arguments:
diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs
index 9393e14f..ac6c3a8e 100644
--- a/src/SMAPI.Installer/Framework/InstallerPaths.cs
+++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs
@@ -8,6 +8,9 @@ namespace StardewModdingAPI.Installer.Framework
/*********
** Accessors
*********/
+ /****
+ ** Main folders
+ ****/
/// <summary>The directory path containing the files to copy into the game folder.</summary>
public DirectoryInfo BundleDir { get; }
@@ -17,9 +20,18 @@ namespace StardewModdingAPI.Installer.Framework
/// <summary>The directory into which to install mods.</summary>
public DirectoryInfo ModsDir { get; }
+ /****
+ ** Installer paths
+ ****/
/// <summary>The full path to directory path containing the files to copy into the game folder.</summary>
public string BundlePath => this.BundleDir.FullName;
+ /// <summary>The full path to the backup API user settings folder, if applicable.</summary>
+ public string BundleApiUserConfigPath { get; }
+
+ /****
+ ** Game paths
+ ****/
/// <summary>The full path to the directory containing the installed game.</summary>
public string GamePath => this.GameDir.FullName;
@@ -29,6 +41,9 @@ namespace StardewModdingAPI.Installer.Framework
/// <summary>The full path to SMAPI's internal configuration file.</summary>
public string ApiConfigPath { get; }
+ /// <summary>The full path to the user's config overrides file.</summary>
+ public string ApiUserConfigPath { get; }
+
/// <summary>The full path to the installed SMAPI executable file.</summary>
public string ExecutablePath { get; }
@@ -55,11 +70,14 @@ namespace StardewModdingAPI.Installer.Framework
this.GameDir = gameDir;
this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods"));
+ this.BundleApiUserConfigPath = Path.Combine(bundleDir.FullName, "smapi-internal", "config.user.json");
+
this.ExecutablePath = Path.Combine(gameDir.FullName, gameExecutableName);
this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley");
this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI");
this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original");
this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json");
+ this.ApiUserConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.user.json");
}
}
}
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs
index 964300ac..2d58baf0 100644
--- a/src/SMAPI.Installer/InteractiveInstaller.cs
+++ b/src/SMAPI.Installer/InteractiveInstaller.cs
@@ -352,6 +352,12 @@ namespace StardewModdingApi.Installer
Console.WriteLine();
/****
+ ** Back up user settings
+ ****/
+ if (File.Exists(paths.ApiUserConfigPath))
+ File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath);
+
+ /****
** Always uninstall old files
****/
// restore game launcher
@@ -373,6 +379,21 @@ namespace StardewModdingApi.Installer
this.InteractivelyDelete(path);
}
+ // move global save data folder (changed in 3.2)
+ {
+ string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
+ DirectoryInfo oldDir = new DirectoryInfo(Path.Combine(dataPath, "Saves", ".smapi"));
+ DirectoryInfo newDir = new DirectoryInfo(Path.Combine(dataPath, ".smapi"));
+
+ if (oldDir.Exists)
+ {
+ if (newDir.Exists)
+ this.InteractivelyDelete(oldDir.FullName);
+ else
+ oldDir.MoveTo(newDir.FullName);
+ }
+ }
+
/****
** Install new files
****/
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs
index a0b739f8..d4d36e5d 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs
@@ -12,8 +12,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <summary>The command description.</summary>
string Description { get; }
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- bool NeedsUpdate { get; }
+ /// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary>
+ bool MayNeedUpdate { get; }
+
+ /// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary>
+ bool MayNeedInput { get; }
/*********
@@ -27,6 +30,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- void Update(IMonitor monitor);
+ void OnUpdated(IMonitor monitor);
+
+ /// <summary>Perform any logic when input is received.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="button">The button that was pressed.</param>
+ void OnButtonPressed(IMonitor monitor, SButton button);
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs
new file mode 100644
index 00000000..11aa10c3
--- /dev/null
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs
@@ -0,0 +1,59 @@
+using System;
+
+namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
+{
+ /// <summary>A command which logs the keys being pressed for 30 seconds once enabled.</summary>
+ internal class TestInputCommand : TrainerCommand
+ {
+ /*********
+ ** Fields
+ *********/
+ /// <summary>The number of seconds for which to log input.</summary>
+ private readonly int LogSeconds = 30;
+
+ /// <summary>When the command should stop printing input, or <c>null</c> if currently disabled.</summary>
+ private long? ExpiryTicks;
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public TestInputCommand()
+ : base("test_input", "Prints all input to the console for 30 seconds.", mayNeedUpdate: true, mayNeedInput: true) { }
+
+ /// <summary>Handle the command.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="command">The command name.</param>
+ /// <param name="args">The command arguments.</param>
+ public override void Handle(IMonitor monitor, string command, ArgumentParser args)
+ {
+ this.ExpiryTicks = DateTime.UtcNow.Add(TimeSpan.FromSeconds(this.LogSeconds)).Ticks;
+ monitor.Log($"OK, logging all player input for {this.LogSeconds} seconds.", LogLevel.Info);
+ }
+
+ /// <summary>Perform any logic needed on update tick.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ public override void OnUpdated(IMonitor monitor)
+ {
+ // handle expiry
+ if (this.ExpiryTicks == null)
+ return;
+ if (this.ExpiryTicks <= DateTime.UtcNow.Ticks)
+ {
+ monitor.Log("No longer logging input.", LogLevel.Info);
+ this.ExpiryTicks = null;
+ return;
+ }
+ }
+
+ /// <summary>Perform any logic when input is received.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="button">The button that was pressed.</param>
+ public override void OnButtonPressed(IMonitor monitor, SButton button)
+ {
+ if (this.ExpiryTicks != null)
+ monitor.Log($"Pressed {button}", LogLevel.Info);
+ }
+ }
+}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs
index 1abb82b5..59bda5dd 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
@@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/*********
- ** Accessors
- *********/
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- public override bool NeedsUpdate => this.InfiniteHealth;
-
-
- /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public SetHealthCommand()
- : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.") { }
+ : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.", mayNeedUpdate: true) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- public override void Update(IMonitor monitor)
+ public override void OnUpdated(IMonitor monitor)
{
- if (this.InfiniteHealth)
+ if (this.InfiniteHealth && Context.IsWorldReady)
Game1.player.health = Game1.player.maxHealth;
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs
index 1706bbc1..6e3d68b6 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs
@@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/*********
- ** Accessors
- *********/
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- public override bool NeedsUpdate => this.InfiniteMoney;
-
-
- /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public SetMoneyCommand()
- : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.") { }
+ : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.", mayNeedUpdate: true) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- public override void Update(IMonitor monitor)
+ public override void OnUpdated(IMonitor monitor)
{
- if (this.InfiniteMoney)
+ if (this.InfiniteMoney && Context.IsWorldReady)
Game1.player.Money = 999999;
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs
index 009cb1de..60a1dcb1 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
@@ -14,18 +14,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/*********
- ** Accessors
- *********/
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- public override bool NeedsUpdate => this.InfiniteStamina;
-
-
- /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public SetStaminaCommand()
- : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.") { }
+ : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.", mayNeedUpdate: true) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- public override void Update(IMonitor monitor)
+ public override void OnUpdated(IMonitor monitor)
{
- if (this.InfiniteStamina)
+ if (this.InfiniteStamina && Context.IsWorldReady)
Game1.player.stamina = Game1.player.MaxStamina;
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs
index 2b562a08..77a26c6a 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs
@@ -16,8 +16,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <summary>The command description.</summary>
public string Description { get; }
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- public virtual bool NeedsUpdate { get; } = false;
+ /// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary>
+ public bool MayNeedInput { get; }
+
+ /// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary>
+ public bool MayNeedUpdate { get; }
/*********
@@ -31,7 +34,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- public virtual void Update(IMonitor monitor) { }
+ public virtual void OnUpdated(IMonitor monitor) { }
+
+ /// <summary>Perform any logic when input is received.</summary>
+ /// <param name="monitor">Writes messages to the console and log file.</param>
+ /// <param name="button">The button that was pressed.</param>
+ public virtual void OnButtonPressed(IMonitor monitor, SButton button) { }
/*********
@@ -40,10 +48,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
/// <summary>Construct an instance.</summary>
/// <param name="name">The command name the user must type.</param>
/// <param name="description">The command description.</param>
- protected TrainerCommand(string name, string description)
+ /// <param name="mayNeedInput">Whether the command may need to perform logic when the player presses a button.</param>
+ /// <param name="mayNeedUpdate">Whether the command may need to perform logic when the game updates.</param>
+ protected TrainerCommand(string name, string description, bool mayNeedInput = false, bool mayNeedUpdate = false)
{
this.Name = name;
this.Description = description;
+ this.MayNeedInput = mayNeedInput;
+ this.MayNeedUpdate = mayNeedUpdate;
}
/// <summary>Log an error indicating incorrect usage.</summary>
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs
index 6a7ab162..736a93a0 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs
@@ -1,4 +1,4 @@
-using System.Linq;
+using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@@ -17,18 +17,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
/*********
- ** Accessors
- *********/
- /// <summary>Whether the command needs to perform logic when the game updates.</summary>
- public override bool NeedsUpdate => this.FreezeTime;
-
-
- /*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public FreezeTimeCommand()
- : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).") { }
+ : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@@ -57,9 +50,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
/// <summary>Perform any logic needed on update tick.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
- public override void Update(IMonitor monitor)
+ public override void OnUpdated(IMonitor monitor)
{
- if (this.FreezeTime)
+ if (this.FreezeTime && Context.IsWorldReady)
Game1.timeOfDay = FreezeTimeCommand.FrozenTime;
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs
index 4807c46d..5c4f3bba 100644
--- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using StardewModdingAPI.Events;
using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands;
namespace StardewModdingAPI.Mods.ConsoleCommands
@@ -14,6 +15,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
/// <summary>The commands to handle.</summary>
private ITrainerCommand[] Commands;
+ /// <summary>The commands which may need to handle update ticks.</summary>
+ private ITrainerCommand[] UpdateHandlers;
+
+ /// <summary>The commands which may need to handle input.</summary>
+ private ITrainerCommand[] InputHandlers;
+
/*********
** Public methods
@@ -27,27 +34,35 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
foreach (ITrainerCommand command in this.Commands)
helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args));
+ // cache commands
+ this.InputHandlers = this.Commands.Where(p => p.MayNeedInput).ToArray();
+ this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray();
+
// hook events
helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked;
+ helper.Events.Input.ButtonPressed += this.OnButtonPressed;
}
/*********
** Private methods
*********/
+ /// <summary>The method invoked when a button is pressed.</summary>
+ /// <param name="sender">The event sender.</param>
+ /// <param name="e">The event arguments.</param>
+ private void OnButtonPressed(object sender, ButtonPressedEventArgs e)
+ {
+ foreach (ITrainerCommand command in this.InputHandlers)
+ command.OnButtonPressed(this.Monitor, e.Button);
+ }
+
/// <summary>The method invoked when the game updates its state.</summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The event arguments.</param>
private void OnUpdateTicked(object sender, EventArgs e)
{
- if (!Context.IsWorldReady)
- return;
-
- foreach (ITrainerCommand command in this.Commands)
- {
- if (command.NeedsUpdate)
- command.Update(this.Monitor);
- }
+ foreach (ITrainerCommand command in this.UpdateHandlers)
+ command.OnUpdated(this.Monitor);
}
/// <summary>Handle a console command.</summary>
diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
index 48afcaa2..ac4ef39b 100644
--- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
+++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs
@@ -17,7 +17,8 @@ namespace SMAPI.Tests.Utilities
/****
** Constructor
****/
- [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")]
+ /// <summary>Assert the parsed version when constructed from a standard string.</summary>
+ /// <param name="input">The version string to parse.</param>
[TestCase("1.0", ExpectedResult = "1.0.0")]
[TestCase("1.0.0", ExpectedResult = "1.0.0")]
[TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")]
@@ -29,10 +30,76 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
public string Constructor_FromString(string input)
{
- return new SemanticVersion(input).ToString();
+ // act
+ ISemanticVersion version = new SemanticVersion(input);
+
+ // assert
+ return version.ToString();
}
- [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")]
+
+ /// <summary>Assert that the constructor rejects invalid values when constructed from a string.</summary>
+ /// <param name="input">The version string to parse.</param>
+ [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")]
+ [TestCase(null)]
+ [TestCase("")]
+ [TestCase(" ")]
+ [TestCase("1")]
+ [TestCase("01.0")]
+ [TestCase("1.05")]
+ [TestCase("1.5.06")] // leading zeros specifically prohibited by spec
+ [TestCase("1.2.3.4")]
+ [TestCase("1.apple")]
+ [TestCase("1.2.apple")]
+ [TestCase("1.2.3.apple")]
+ [TestCase("1..2..3")]
+ [TestCase("1.2.3-")]
+ [TestCase("1.2.3--some-tag")]
+ [TestCase("1.2.3-some-tag...")]
+ [TestCase("1.2.3-some-tag...4")]
+ [TestCase("1.2.3-some-tag.4+build...4")]
+ [TestCase("apple")]
+ [TestCase("-apple")]
+ [TestCase("-5")]
+ public void Constructor_FromString_WithInvalidValues(string input)
+ {
+ if (input == null)
+ this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input));
+ else
+ this.AssertAndLogException<FormatException>(() => new SemanticVersion(input));
+ }
+
+ /// <summary>Assert the parsed version when constructed from a non-standard string.</summary>
+ /// <param name="input">The version string to parse.</param>
+ [TestCase("1.2.3", ExpectedResult = "1.2.3")]
+ [TestCase("1.0.0.0", ExpectedResult = "1.0.0")]
+ [TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")]
+ [TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")]
+ public string Constructor_FromString_NonStandard(string input)
+ {
+ // act
+ ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true);
+
+ // assert
+ return version.ToString();
+ }
+
+ /// <summary>Assert that the constructor rejects a non-standard string when the non-standard flag isn't set.</summary>
+ /// <param name="input">The version string to parse.</param>
+ [TestCase("1.0.0.0")]
+ [TestCase("1.0.0.5")]
+ [TestCase("1.2.3.4-some-tag.4 ")]
+ public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input)
+ {
+ Assert.Throws<FormatException>(() => new SemanticVersion(input));
+ }
+
+ /// <summary>Assert the parsed version when constructed from standard parts.</summary>
+ /// <param name="major">The major number.</param>
+ /// <param name="minor">The minor number.</param>
+ /// <param name="patch">The patch number.</param>
+ /// <param name="prerelease">The prerelease tag.</param>
+ /// <param name="build">The build metadata.</param>
[TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")]
[TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")]
[TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")]
@@ -49,15 +116,43 @@ namespace SMAPI.Tests.Utilities
ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);
// assert
- Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value.");
- Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value.");
- Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value.");
- Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match the given value.");
- Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match the given value.");
+ this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false);
return version.ToString();
}
- [Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")]
+ /// <summary>Assert the parsed version when constructed from parts including non-standard fields.</summary>
+ /// <param name="major">The major number.</param>
+ /// <param name="minor">The minor number.</param>
+ /// <param name="patch">The patch number.</param>
+ /// <param name="platformRelease">The non-standard platform release number.</param>
+ /// <param name="prerelease">The prerelease tag.</param>
+ /// <param name="build">The build metadata.</param>
+ [TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")]
+ [TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")]
+ [TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")]
+ [TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")]
+ [TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")]
+ [TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")]
+ [TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")]
+ [TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")]
+ [TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")]
+ [TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")]
+ public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build)
+ {
+ // act
+ ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build);
+
+ // assert
+ this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0);
+ return version.ToString();
+ }
+
+ /// <summary>Assert that the constructor rejects invalid values when constructed from the individual numbers.</summary>
+ /// <param name="major">The major number.</param>
+ /// <param name="minor">The minor number.</param>
+ /// <param name="patch">The patch number.</param>
+ /// <param name="prerelease">The prerelease tag.</param>
+ /// <param name="build">The build metadata.</param>
[TestCase(0, 0, 0, null, null)]
[TestCase(-1, 0, 0, null, null)]
[TestCase(0, -1, 0, null, null)]
@@ -71,6 +166,10 @@ namespace SMAPI.Tests.Utilities
this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build));
}
+ /// <summary>Assert the parsed version when constructed from an assembly version.</summary>
+ /// <param name="major">The major number.</param>
+ /// <param name="minor">The minor number.</param>
+ /// <param name="patch">The patch number.</param>
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")]
[TestCase(1, 0, 0, ExpectedResult = "1.0.0")]
[TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
@@ -81,45 +180,16 @@ namespace SMAPI.Tests.Utilities
ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch));
// assert
- Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value.");
- Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value.");
- Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value.");
+ this.AssertParts(version, major, minor, patch, null, null, nonStandard: false);
return version.ToString();
}
- [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")]
- [TestCase(null)]
- [TestCase("")]
- [TestCase(" ")]
- [TestCase("1")]
- [TestCase("01.0")]
- [TestCase("1.05")]
- [TestCase("1.5.06")] // leading zeros specifically prohibited by spec
- [TestCase("1.2.3.4")]
- [TestCase("1.apple")]
- [TestCase("1.2.apple")]
- [TestCase("1.2.3.apple")]
- [TestCase("1..2..3")]
- [TestCase("1.2.3-")]
- [TestCase("1.2.3--some-tag")]
- [TestCase("1.2.3-some-tag...")]
- [TestCase("1.2.3-some-tag...4")]
- [TestCase("1.2.3-some-tag.4+build...4")]
- [TestCase("apple")]
- [TestCase("-apple")]
- [TestCase("-5")]
- public void Constructor_FromString_WithInvalidValues(string input)
- {
- if (input == null)
- this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input));
- else
- this.AssertAndLogException<FormatException>(() => new SemanticVersion(input));
- }
-
/****
** CompareTo
****/
- [Test(Description = "Assert that version.CompareTo returns the expected value.")]
+ /// <summary>Assert that <see cref="ISemanticVersion.CompareTo"/> returns the expected value.</summary>
+ /// <param name="versionStrA">The left version.</param>
+ /// <param name="versionStrB">The right version.</param>
// equal
[TestCase("0.5.7", "0.5.7", ExpectedResult = 0)]
[TestCase("1.0", "1.0", ExpectedResult = 0)]
@@ -149,15 +219,20 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)]
public int CompareTo(string versionStrA, string versionStrB)
{
+ // arrange
ISemanticVersion versionA = new SemanticVersion(versionStrA);
ISemanticVersion versionB = new SemanticVersion(versionStrB);
+
+ // assert
return versionA.CompareTo(versionB);
}
/****
** IsOlderThan
****/
- [Test(Description = "Assert that version.IsOlderThan returns the expected value.")]
+ /// <summary>Assert that <see cref="ISemanticVersion.IsOlderThan(string)"/> and <see cref="ISemanticVersion.IsOlderThan(ISemanticVersion)"/> return the expected value.</summary>
+ /// <param name="versionStrA">The left version.</param>
+ /// <param name="versionStrB">The right version.</param>
// keep test cases in sync with CompareTo for simplicity.
// equal
[TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
@@ -187,15 +262,21 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)]
public bool IsOlderThan(string versionStrA, string versionStrB)
{
+ // arrange
ISemanticVersion versionA = new SemanticVersion(versionStrA);
ISemanticVersion versionB = new SemanticVersion(versionStrB);
+
+ // assert
+ Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB.ToString()), "The two signatures returned different results.");
return versionA.IsOlderThan(versionB);
}
/****
** IsNewerThan
****/
- [Test(Description = "Assert that version.IsNewerThan returns the expected value.")]
+ /// <summary>Assert that <see cref="ISemanticVersion.IsNewerThan(string)"/> and <see cref="ISemanticVersion.IsNewerThan(ISemanticVersion)"/> return the expected value.</summary>
+ /// <param name="versionStrA">The left version.</param>
+ /// <param name="versionStrB">The right version.</param>
// keep test cases in sync with CompareTo for simplicity.
// equal
[TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
@@ -225,14 +306,22 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)]
public bool IsNewerThan(string versionStrA, string versionStrB)
{
+ // arrange
ISemanticVersion versionA = new SemanticVersion(versionStrA);
ISemanticVersion versionB = new SemanticVersion(versionStrB);
+
+ // assert
+ Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB.ToString()), "The two signatures returned different results.");
return versionA.IsNewerThan(versionB);
}
/****
** IsBetween
****/
+ /// <summary>Assert that <see cref="ISemanticVersion.IsBetween(string, string)"/> and <see cref="ISemanticVersion.IsBetween(ISemanticVersion, ISemanticVersion)"/> return the expected value.</summary>
+ /// <param name="versionStr">The main version.</param>
+ /// <param name="lowerStr">The lower version number.</param>
+ /// <param name="upperStr">The upper version number.</param>
[Test(Description = "Assert that version.IsNewerThan returns the expected value.")]
// is between
[TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)]
@@ -250,17 +339,24 @@ namespace SMAPI.Tests.Utilities
[TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)]
public bool IsBetween(string versionStr, string lowerStr, string upperStr)
{
+ // arrange
ISemanticVersion lower = new SemanticVersion(lowerStr);
ISemanticVersion upper = new SemanticVersion(upperStr);
ISemanticVersion version = new SemanticVersion(versionStr);
+
+ // assert
+ Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower.ToString(), upper.ToString()), "The two signatures returned different results.");
return version.IsBetween(lower, upper);
}
/****
** Serializable
****/
- [Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")]
+ /// <summary>Assert that the version can be round-tripped through JSON with no special configuration.</summary>
+ /// <param name="versionStr">The semantic version.</param>
[TestCase("1.0.0")]
+ [TestCase("1.0.0-beta.400")]
+ [TestCase("1.0.0-beta.400+build")]
public void Serializable(string versionStr)
{
// act
@@ -272,10 +368,12 @@ namespace SMAPI.Tests.Utilities
Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version.");
}
+
/****
** GameVersion
****/
- [Test(Description = "Assert that the GameVersion subclass correctly parses legacy game versions.")]
+ /// <summary>Assert that the GameVersion subclass correctly parses non-standard game versions.</summary>
+ /// <param name="versionStr">The raw version.</param>
[TestCase("1.0")]
[TestCase("1.01")]
[TestCase("1.02")]
@@ -307,6 +405,24 @@ namespace SMAPI.Tests.Utilities
/*********
** Private methods
*********/
+ /// <summary>Assert that the version matches the expected parts.</summary>
+ /// <param name="version">The version number.</param>
+ /// <param name="major">The major number.</param>
+ /// <param name="minor">The minor number.</param>
+ /// <param name="patch">The patch number.</param>
+ /// <param name="prerelease">The prerelease tag.</param>
+ /// <param name="build">The build metadata.</param>
+ /// <param name="nonStandard">Whether the version should be marked as non-standard.</param>
+ private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string prerelease, string build, bool nonStandard)
+ {
+ Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match.");
+ Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match.");
+ Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match.");
+ Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match.");
+ Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match.");
+ Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}.");
+ }
+
/// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary>
/// <typeparam name="T">The expected exception type.</typeparam>
/// <param name="action">The action which may throw the exception.</param>
diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
index b8572d50..b228b2d1 100644
--- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
+++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs
@@ -61,5 +61,8 @@ namespace StardewModdingAPI
/// <summary>Get a string representation of the version.</summary>
string ToString();
+
+ /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
+ bool IsNonStandard();
}
}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
index f1bcfccc..2f58a3f1 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
{
/// <summary>Metadata about a mod.</summary>
@@ -17,26 +15,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <summary>Optional extended data which isn't needed for update checks.</summary>
public ModExtendedMetadataModel Metadata { get; set; }
- /// <summary>The main version.</summary>
- [Obsolete]
- public ModEntryVersionModel Main { get; set; }
-
- /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary>
- [Obsolete]
- public ModEntryVersionModel Optional { get; set; }
-
- /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
- [Obsolete]
- public ModEntryVersionModel Unofficial { get; set; }
-
- /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary>
- [Obsolete]
- public ModEntryVersionModel UnofficialForBeta { get; set; }
-
- /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary>
- [Obsolete]
- public bool? HasBetaInfo { get; set; }
-
/// <summary>The errors that occurred while fetching update data.</summary>
public string[] Errors { get; set; } = new string[0];
}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
index 4a697585..8c21e4e0 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs
@@ -55,7 +55,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
public ModEntryVersionModel Unofficial { get; set; }
- /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary>
+ /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any.</summary>
public ModEntryVersionModel UnofficialForBeta { get; set; }
/****
@@ -84,6 +84,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
/// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary>
public string BetaBrokeIn { get; set; }
+ /****
+ ** Version mappings
+ ****/
+ /// <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; }
+
/*********
** Public methods
@@ -127,13 +136,16 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status;
this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary;
this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn;
+
+ this.MapLocalVersions = wiki.MapLocalVersions;
+ this.MapRemoteVersions = wiki.MapRemoteVersions;
}
// internal DB data
if (db != null)
{
this.ID = this.ID.Union(db.FormerIDs).ToArray();
- this.Name = this.Name ?? db.DisplayName;
+ this.Name ??= db.DisplayName;
}
}
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
index 384f23fc..c829c0f4 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs
@@ -102,6 +102,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
string anchor = this.GetAttribute(node, "id");
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");
@@ -132,15 +133,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
}
}
- // parse links
- List<Tuple<Uri, string>> metadataLinks = new List<Tuple<Uri, string>>();
- foreach (HtmlNode linkElement in node.Descendants("td").Last().Descendants("a").Skip(1)) // skip anchor link
- {
- string text = linkElement.InnerText.Trim();
- Uri url = new Uri(linkElement.GetAttributeValue("href", ""));
- metadataLinks.Add(Tuple.Create(url, text));
- }
-
// yield model
yield return new WikiModEntry
{
@@ -159,7 +151,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
Compatibility = compatibility,
BetaCompatibility = betaCompatibility,
Warnings = warnings,
- MetadataLinks = metadataLinks.ToArray(),
+ PullRequestUrl = pullRequestUrl,
DevNote = devNote,
MapLocalVersions = mapLocalVersions,
MapRemoteVersions = mapRemoteVersions,
diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
index 931dcd43..474dce3d 100644
--- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
+++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs
@@ -57,8 +57,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
/// <summary>The human-readable warnings for players about this mod.</summary>
public string[] Warnings { get; set; }
- /// <summary>Extra metadata links (usually for open pull requests).</summary>
- public Tuple<Uri, string>[] MetadataLinks { get; set; }
+ /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
+ public string PullRequestUrl { get; set; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
public string DevNote { get; set; }
diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs
new file mode 100644
index 00000000..489e1c4d
--- /dev/null
+++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs
@@ -0,0 +1,126 @@
+namespace StardewModdingAPI.Toolkit.Framework
+{
+ /// <summary>Reads strings into a semantic version.</summary>
+ internal static class SemanticVersionReader
+ {
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Parse a semantic version string.</summary>
+ /// <param name="versionStr">The version string to parse.</param>
+ /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param>
+ /// <param name="major">The major version incremented for major API changes.</param>
+ /// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
+ /// <param name="patch">The patch version for backwards-compatible fixes.</param>
+ /// <param name="platformRelease">The platform-specific version (if applicable).</param>
+ /// <param name="prereleaseTag">An optional prerelease tag.</param>
+ /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
+ /// <returns>Returns whether the version was successfully parsed.</returns>
+ public static bool TryParse(string versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata)
+ {
+ // init
+ major = 0;
+ minor = 0;
+ patch = 0;
+ platformRelease = 0;
+ prereleaseTag = null;
+ buildMetadata = null;
+
+ // normalize
+ versionStr = versionStr?.Trim();
+ if (string.IsNullOrWhiteSpace(versionStr))
+ return false;
+ char[] raw = versionStr.ToCharArray();
+
+ // read major/minor version
+ int i = 0;
+ if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor))
+ return false;
+
+ // read optional patch version
+ if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch))
+ return false;
+
+ // read optional non-standard platform release version
+ if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease))
+ return false;
+
+ // read optional prerelease tag
+ if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag))
+ return false;
+
+ // read optional build tag
+ if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata))
+ return false;
+
+ // validate
+ return i == versionStr.Length; // valid if we're at the end
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Try to parse the next characters in a queue as a numeric part.</summary>
+ /// <param name="raw">The raw characters to parse.</param>
+ /// <param name="index">The index of the next character to read.</param>
+ /// <param name="part">The parsed part.</param>
+ private static bool TryParseVersionPart(char[] raw, ref int index, out int part)
+ {
+ part = 0;
+
+ // take digits
+ string str = "";
+ for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++)
+ str += raw[i];
+
+ // validate
+ if (str.Length == 0)
+ return false;
+ if (str.Length > 1 && str[0] == '0')
+ return false; // can't have leading zeros
+
+ // parse
+ part = int.Parse(str);
+ index += str.Length;
+ return true;
+ }
+
+ /// <summary>Try to parse a literal character.</summary>
+ /// <param name="raw">The raw characters to parse.</param>
+ /// <param name="index">The index of the next character to read.</param>
+ /// <param name="ch">The expected character.</param>
+ private static bool TryParseLiteral(char[] raw, ref int index, char ch)
+ {
+ if (index >= raw.Length || raw[index] != ch)
+ return false;
+
+ index++;
+ return true;
+ }
+
+ /// <summary>Try to parse a tag.</summary>
+ /// <param name="raw">The raw characters to parse.</param>
+ /// <param name="index">The index of the next character to read.</param>
+ /// <param name="tag">The parsed tag.</param>
+ private static bool TryParseTag(char[] raw, ref int index, out string tag)
+ {
+ // read tag length
+ int length = 0;
+ for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++)
+ length++;
+
+ // validate
+ if (length == 0)
+ {
+ tag = null;
+ return false;
+ }
+
+ // parse
+ tag = new string(raw, index, length);
+ index += length;
+ return true;
+ }
+ }
+}
diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs
index 4955dcae..5ead6dc8 100644
--- a/src/SMAPI.Toolkit/SemanticVersion.cs
+++ b/src/SMAPI.Toolkit/SemanticVersion.cs
@@ -1,5 +1,6 @@
using System;
using System.Text.RegularExpressions;
+using StardewModdingAPI.Toolkit.Framework;
namespace StardewModdingAPI.Toolkit
{
@@ -9,6 +10,8 @@ namespace StardewModdingAPI.Toolkit
/// - short-form "x.y" versions are supported (equivalent to "x.y.0");
/// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild");
/// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial").
+ ///
+ /// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.
/// </remarks>
public class SemanticVersion : ISemanticVersion
{
@@ -16,14 +19,7 @@ namespace StardewModdingAPI.Toolkit
** Fields
*********/
/// <summary>A regex pattern matching a valid prerelease or build metadata tag.</summary>
- internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
-
- /// <summary>A regex pattern matching a version within a larger string.</summary>
- internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?(?:\\+(?<buildmetadata>" + SemanticVersion.TagPattern + "))?";
-
- /// <summary>A regular expression matching a semantic version string.</summary>
- /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks>
- internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
+ private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
/*********
@@ -38,6 +34,9 @@ namespace StardewModdingAPI.Toolkit
/// <summary>The patch version for backwards-compatible bug fixes.</summary>
public int PatchVersion { get; }
+ /// <summary>The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.</summary>
+ public int PlatformRelease { get; }
+
/// <summary>An optional prerelease tag.</summary>
public string PrereleaseTag { get; }
@@ -52,13 +51,15 @@ namespace StardewModdingAPI.Toolkit
/// <param name="major">The major version incremented for major API changes.</param>
/// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patch">The patch version for backwards-compatible fixes.</param>
+ /// <param name="platformRelease">The platform-specific version (if applicable).</param>
/// <param name="prereleaseTag">An optional prerelease tag.</param>
/// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
- public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null, string buildMetadata = null)
+ public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string prereleaseTag = null, string buildMetadata = null)
{
this.MajorVersion = major;
this.MinorVersion = minor;
this.PatchVersion = patch;
+ this.PlatformRelease = platformRelease;
this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag);
this.BuildMetadata = this.GetNormalizedTag(buildMetadata);
@@ -82,23 +83,22 @@ namespace StardewModdingAPI.Toolkit
/// <summary>Construct an instance.</summary>
/// <param name="version">The semantic version string.</param>
+ /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
/// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
- public SemanticVersion(string version)
+ public SemanticVersion(string version, bool allowNonStandard = false)
{
- // parse
if (version == null)
throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
- var match = SemanticVersion.Regex.Match(version.Trim());
- if (!match.Success)
+ if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) || (!allowNonStandard && platformRelease != 0))
throw new FormatException($"The input '{version}' isn't a valid semantic version.");
- // initialize
- this.MajorVersion = int.Parse(match.Groups["major"].Value);
- this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
- this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
- this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null;
- this.BuildMetadata = match.Groups["buildmetadata"].Success ? this.GetNormalizedTag(match.Groups["buildmetadata"].Value) : null;
+ this.MajorVersion = major;
+ this.MinorVersion = minor;
+ this.PatchVersion = patch;
+ this.PlatformRelease = platformRelease;
+ this.PrereleaseTag = prereleaseTag;
+ this.BuildMetadata = buildMetadata;
this.AssertValid();
}
@@ -110,7 +110,7 @@ namespace StardewModdingAPI.Toolkit
{
if (other == null)
throw new ArgumentNullException(nameof(other));
- return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.PrereleaseTag);
+ return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag);
}
/// <summary>Indicates whether the current object is equal to another object of the same type.</summary>
@@ -139,7 +139,7 @@ namespace StardewModdingAPI.Toolkit
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
public bool IsOlderThan(string other)
{
- return this.IsOlderThan(new SemanticVersion(other));
+ return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true));
}
/// <summary>Get whether this version is newer than the specified version.</summary>
@@ -154,7 +154,7 @@ namespace StardewModdingAPI.Toolkit
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
public bool IsNewerThan(string other)
{
- return this.IsNewerThan(new SemanticVersion(other));
+ return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true));
}
/// <summary>Get whether this version is between two specified versions (inclusively).</summary>
@@ -171,13 +171,15 @@ namespace StardewModdingAPI.Toolkit
/// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception>
public bool IsBetween(string min, string max)
{
- return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max));
+ return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true));
}
/// <summary>Get a string representation of the version.</summary>
public override string ToString()
{
string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}";
+ if (this.PlatformRelease != 0)
+ version += $".{this.PlatformRelease}";
if (this.PrereleaseTag != null)
version += $"-{this.PrereleaseTag}";
if (this.BuildMetadata != null)
@@ -185,15 +187,30 @@ namespace StardewModdingAPI.Toolkit
return version;
}
+ /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
+ public bool IsNonStandard()
+ {
+ return this.PlatformRelease != 0;
+ }
+
/// <summary>Parse a version string without throwing an exception if it fails.</summary>
/// <param name="version">The version string.</param>
/// <param name="parsed">The parsed representation.</param>
/// <returns>Returns whether parsing the version succeeded.</returns>
public static bool TryParse(string version, out ISemanticVersion parsed)
{
+ return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard();
+ }
+
+ /// <summary>Parse a version string without throwing an exception if it fails, including support for non-standard extensions like <see cref="IPlatformSpecificVersion"/>.</summary>
+ /// <param name="version">The version string.</param>
+ /// <param name="parsed">The parsed representation.</param>
+ /// <returns>Returns whether parsing the version succeeded.</returns>
+ public static bool TryParseNonStandard(string version, out ISemanticVersion parsed)
+ {
try
{
- parsed = new SemanticVersion(version);
+ parsed = new SemanticVersion(version, true);
return true;
}
catch
@@ -219,8 +236,9 @@ namespace StardewModdingAPI.Toolkit
/// <param name="otherMajor">The major version to compare with this instance.</param>
/// <param name="otherMinor">The minor version to compare with this instance.</param>
/// <param name="otherPatch">The patch version to compare with this instance.</param>
+ /// <param name="otherPlatformRelease">The non-standard platform release to compare with this instance.</param>
/// <param name="otherTag">The prerelease tag to compare with this instance.</param>
- private int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag)
+ private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string otherTag)
{
const int same = 0;
const int curNewer = 1;
@@ -233,6 +251,8 @@ namespace StardewModdingAPI.Toolkit
return this.MinorVersion.CompareTo(otherMinor);
if (this.PatchVersion != otherPatch)
return this.PatchVersion.CompareTo(otherPatch);
+ if (this.PlatformRelease != otherPlatformRelease)
+ return this.PlatformRelease.CompareTo(otherPlatformRelease);
if (this.PrereleaseTag == otherTag)
return same;
@@ -274,7 +294,7 @@ namespace StardewModdingAPI.Toolkit
}
// fallback (this should never happen)
- return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
+ return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
}
/// <summary>Assert that the current version is valid.</summary>
diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs
index ece4a72e..e1b9db1d 100644
--- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs
+++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs
@@ -68,7 +68,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters
int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion));
string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag));
- return new SemanticVersion(major, minor, patch, prereleaseTag);
+ return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag);
}
/// <summary>Read a JSON string.</summary>
diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs
index 3e3b81c8..f194b4d0 100644
--- a/src/SMAPI.Web/Controllers/ModsApiController.cs
+++ b/src/SMAPI.Web/Controllers/ModsApiController.cs
@@ -94,8 +94,6 @@ namespace StardewModdingAPI.Web.Controllers
if (model?.Mods == null)
return new ModEntryModel[0];
- bool legacyMode = SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion) && parsedVersion.IsOlderThan("3.0.0-beta.20191109");
-
// fetch wiki data
WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray();
IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
@@ -104,19 +102,8 @@ namespace StardewModdingAPI.Web.Controllers
if (string.IsNullOrWhiteSpace(mod.ID))
continue;
- ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata || legacyMode, model.ApiVersion);
- if (legacyMode)
- {
- result.Main = result.Metadata.Main;
- result.Optional = result.Metadata.Optional;
- result.Unofficial = result.Metadata.Unofficial;
- result.UnofficialForBeta = result.Metadata.UnofficialForBeta;
- result.HasBetaInfo = result.Metadata.BetaCompatibilityStatus != null;
- result.SuggestedUpdate = null;
- if (!model.IncludeExtendedMetadata)
- result.Metadata = null;
- }
- else if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null))
+ ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion);
+ if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null))
{
var errors = new List<string>(result.Errors);
errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage.");
diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs
index 8569984a..7e7c99bc 100644
--- a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs
+++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs
@@ -67,8 +67,8 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
/// <summary>The human-readable warnings for players about this mod.</summary>
public string[] Warnings { get; set; }
- /// <summary>Extra metadata links (usually for open pull requests).</summary>
- public Tuple<Uri, string>[] MetadataLinks { get; set; }
+ /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
+ public string PullRequestUrl { get; set; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
public string DevNote { get; set; }
@@ -150,7 +150,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
this.CustomSourceUrl = mod.CustomSourceUrl;
this.CustomUrl = mod.CustomUrl;
this.ContentPackFor = mod.ContentPackFor;
- this.MetadataLinks = mod.MetadataLinks;
+ this.PullRequestUrl = mod.PullRequestUrl;
this.Warnings = mod.Warnings;
this.DevNote = mod.DevNote;
this.Anchor = mod.Anchor;
@@ -192,7 +192,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
CustomUrl = this.CustomUrl,
ContentPackFor = this.ContentPackFor,
Warnings = this.Warnings,
- MetadataLinks = this.MetadataLinks,
+ PullRequestUrl = this.PullRequestUrl,
DevNote = this.DevNote,
Anchor = this.Anchor,
diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
index 939c32c6..cdb281e2 100644
--- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs
@@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
if (name.StartsWith("[SMAPI] "))
name = name.Substring("[SMAPI] ".Length);
- string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText;
+ string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText;
// create model
return new ChucklefishMod
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 1210f708..cc91ec51 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
-using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.LogParsing.Models;
namespace StardewModdingAPI.Web.Framework.LogParsing
@@ -31,22 +30,22 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary>
/// <remarks>The author name and description are optional.</remarks>
- private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary>
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
- private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary>
private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary>
- private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching SMAPI's update line.</summary>
- private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs
index 2d6ec603..72f5ef84 100644
--- a/src/SMAPI.Web/Framework/VersionConstraint.cs
+++ b/src/SMAPI.Web/Framework/VersionConstraint.cs
@@ -1,16 +1,34 @@
-using Microsoft.AspNetCore.Routing.Constraints;
+using System;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework
{
/// <summary>Constrains a route value to a valid semantic version.</summary>
- internal class VersionConstraint : RegexRouteConstraint
+ internal class VersionConstraint : IRouteConstraint
{
/*********
** Public methods
*********/
- /// <summary>Construct an instance.</summary>
- public VersionConstraint()
- : base(SemanticVersion.Regex) { }
+ /// <summary>Get whether the URL parameter contains a valid value for this constraint.</summary>
+ /// <param name="httpContext">An object that encapsulates information about the HTTP request.</param>
+ /// <param name="route">The router that this constraint belongs to.</param>
+ /// <param name="routeKey">The name of the parameter that is being checked.</param>
+ /// <param name="values">A dictionary that contains the parameters for the URL.</param>
+ /// <param name="routeDirection">An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated.</param>
+ /// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns>
+ public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
+ {
+ if (routeKey == null)
+ throw new ArgumentNullException(nameof(routeKey));
+ if (values == null)
+ throw new ArgumentNullException(nameof(values));
+
+ return
+ values.TryGetValue(routeKey, out object routeValue)
+ && routeValue is string routeStr
+ && SemanticVersion.TryParseNonStandard(routeStr, out _);
+ }
}
}
diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs
index 2b478c81..56316ab7 100644
--- a/src/SMAPI.Web/ViewModels/ModModel.cs
+++ b/src/SMAPI.Web/ViewModels/ModModel.cs
@@ -1,4 +1,3 @@
-using System;
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
@@ -38,8 +37,8 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The human-readable warnings for players about this mod.</summary>
public string[] Warnings { get; set; }
- /// <summary>Extra metadata links (usually for open pull requests).</summary>
- public Tuple<Uri, string>[] MetadataLinks { get; set; }
+ /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
+ public string PullRequestUrl { get; set; }
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
public string DevNote { get; set; }
@@ -68,7 +67,7 @@ namespace StardewModdingAPI.Web.ViewModels
this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null;
this.ModPages = this.GetModPageUrls(entry).ToArray();
this.Warnings = entry.Warnings;
- this.MetadataLinks = entry.MetadataLinks;
+ this.PullRequestUrl = entry.PullRequestUrl;
this.DevNote = entry.DevNote;
this.Slug = entry.Anchor;
}
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
index 778da2d1..eded9df3 100644
--- a/src/SMAPI.Web/Views/Index/Index.cshtml
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -53,9 +53,11 @@
<div class="area">
<h2 id="help">Get help</h2>
<ul>
+ <li><a href="https://smapi.io/community">Ask on Discord</a></li>
+ <li><a href="https://reddit.com/r/SMAPI">Ask on Reddit</a></li>
<li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li>
- <li><a href="https://smapi.io/community">Ask questions</a> (or join the community!)</li>
</ul>
+ (Or join the community!)
</div>
<div class="area">
diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml
index 50b59b45..5b310d55 100644
--- a/src/SMAPI.Web/Views/Mods/Index.cshtml
+++ b/src/SMAPI.Web/Views/Mods/Index.cshtml
@@ -110,11 +110,8 @@ else
<small>
<a v-bind:href="'#' + mod.Slug">#</a>
<span v-show="showAdvanced">
- <template v-for="(link, i) in mod.MetadataLinks">
- <a v-bind:href="link.Item1">{{link.Item2}}</a>
- </template>
-
- <abbr v-bind:title="mod.DevNote" v-show="mod.DevNote">[dev note]</abbr>
+ <a v-bind:href="mod.PullRequestUrl" v-if="mod.PullRequestUrl">PR</a>
+ <abbr v-bind:title="mod.DevNote" v-if="mod.DevNote">[dev note]</abbr>
</span>
</small>
</td>
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 67c7b576..8afe4b52 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -64,6 +64,9 @@ namespace StardewModdingAPI
/// <summary>The file path for the SMAPI configuration file.</summary>
internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json");
+ /// <summary>The file path for the overrides file for <see cref="ApiConfigPath"/>, which is applied over it.</summary>
+ internal static string ApiUserConfigPath => Path.Combine(Constants.InternalFilesPath, "config.user.json");
+
/// <summary>The file path for the SMAPI metadata file.</summary>
internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json");
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index f33ff84d..b0933ac6 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -4,7 +4,6 @@ using System.Diagnostics.Contracts;
using System.Linq;
using Microsoft.Xna.Framework;
using StardewModdingAPI.Framework.Reflection;
-using StardewModdingAPI.Internal;
using StardewModdingAPI.Toolkit.Utilities;
using StardewValley;
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 82d3805b..b60483f1 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Linq;
+using System.Threading;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
@@ -48,6 +50,10 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether the content coordinator has been disposed.</summary>
private bool IsDisposed;
+ /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary>
+ /// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks>
+ private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim();
+
/*********
** Accessors
@@ -96,9 +102,12 @@ namespace StardewModdingAPI.Framework
/// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
public GameContentManager CreateGameContentManager(string name)
{
- GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
- this.ContentManagers.Add(manager);
- return manager;
+ return this.ContentManagerLock.InWriteLock(() =>
+ {
+ GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
+ this.ContentManagers.Add(manager);
+ return manager;
+ });
}
/// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
@@ -107,20 +116,23 @@ namespace StardewModdingAPI.Framework
/// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param>
public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager)
{
- ModContentManager manager = new ModContentManager(
- name: name,
- gameContentManager: gameContentManager,
- serviceProvider: this.MainContentManager.ServiceProvider,
- rootDirectory: rootDirectory,
- currentCulture: this.MainContentManager.CurrentCulture,
- coordinator: this,
- monitor: this.Monitor,
- reflection: this.Reflection,
- jsonHelper: this.JsonHelper,
- onDisposing: this.OnDisposing
- );
- this.ContentManagers.Add(manager);
- return manager;
+ return this.ContentManagerLock.InWriteLock(() =>
+ {
+ ModContentManager manager = new ModContentManager(
+ name: name,
+ gameContentManager: gameContentManager,
+ serviceProvider: this.MainContentManager.ServiceProvider,
+ rootDirectory: rootDirectory,
+ currentCulture: this.MainContentManager.CurrentCulture,
+ coordinator: this,
+ monitor: this.Monitor,
+ reflection: this.Reflection,
+ jsonHelper: this.JsonHelper,
+ onDisposing: this.OnDisposing
+ );
+ this.ContentManagers.Add(manager);
+ return manager;
+ });
}
/// <summary>Get the current content locale.</summary>
@@ -132,8 +144,11 @@ namespace StardewModdingAPI.Framework
/// <summary>Perform any cleanup needed when the locale changes.</summary>
public void OnLocaleChanged()
{
- foreach (IContentManager contentManager in this.ContentManagers)
- contentManager.OnLocaleChanged();
+ this.ContentManagerLock.InReadLock(() =>
+ {
+ foreach (IContentManager contentManager in this.ContentManagers)
+ contentManager.OnLocaleChanged();
+ });
}
/// <summary>Get whether this asset is mapped to a mod folder.</summary>
@@ -180,7 +195,9 @@ namespace StardewModdingAPI.Framework
public T LoadManagedAsset<T>(string contentManagerID, string relativePath)
{
// get content manager
- IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID);
+ IContentManager contentManager = this.ContentManagerLock.InReadLock(() =>
+ this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID)
+ );
if (contentManager == null)
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
@@ -210,15 +227,18 @@ namespace StardewModdingAPI.Framework
{
// invalidate cache & track removed assets
IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
- foreach (IContentManager contentManager in this.ContentManagers)
+ this.ContentManagerLock.InReadLock(() =>
{
- foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ foreach (IContentManager contentManager in this.ContentManagers)
{
- if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
- removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
- assets.Add(entry.Value);
+ foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ {
+ if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
+ removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
+ assets.Add(entry.Value);
+ }
}
- }
+ });
// reload core game assets
if (removedAssets.Any())
@@ -232,6 +252,23 @@ namespace StardewModdingAPI.Framework
return removedAssets.Keys;
}
+ /// <summary>Get all loaded instances of an asset name.</summary>
+ /// <param name="assetName">The asset name.</param>
+ [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")]
+ public IEnumerable<object> GetLoadedValues(string assetName)
+ {
+ return this.ContentManagerLock.InReadLock(() =>
+ {
+ List<object> values = new List<object>();
+ foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName)))
+ {
+ object value = content.Load<object>(assetName, this.Language, useCache: true);
+ values.Add(value);
+ }
+ return values;
+ });
+ }
+
/// <summary>Dispose held resources.</summary>
public void Dispose()
{
@@ -244,6 +281,8 @@ namespace StardewModdingAPI.Framework
contentManager.Dispose();
this.ContentManagers.Clear();
this.MainContentManager = null;
+
+ this.ContentManagerLock.Dispose();
}
@@ -257,7 +296,9 @@ namespace StardewModdingAPI.Framework
if (this.IsDisposed)
return;
- this.ContentManagers.Remove(contentManager);
+ this.ContentManagerLock.InWriteLock(() =>
+ this.ContentManagers.Remove(contentManager)
+ );
}
}
}
diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs
index 29cfbc39..07957624 100644
--- a/src/SMAPI/Framework/GameVersion.cs
+++ b/src/SMAPI/Framework/GameVersion.cs
@@ -3,8 +3,8 @@ using System.Collections.Generic;
namespace StardewModdingAPI.Framework
{
- /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary>
- internal class GameVersion : SemanticVersion
+ /// <summary>An extension of <see cref="ISemanticVersion"/> that correctly handles non-semantic versions used by Stardew Valley.</summary>
+ internal class GameVersion : Toolkit.SemanticVersion
{
/*********
** Private methods
@@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework
["1.03"] = "1.0.3",
["1.04"] = "1.0.4",
["1.05"] = "1.0.5",
- ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes.
- ["1.051b"] = "1.0.6-prerelease2",
+ ["1.051"] = "1.0.5.1",
+ ["1.051b"] = "1.0.5.2",
["1.06"] = "1.0.6",
["1.07"] = "1.0.7",
- ["1.07a"] = "1.0.8-prerelease1",
+ ["1.07a"] = "1.0.7.1",
["1.08"] = "1.0.8",
["1.1"] = "1.1.0",
["1.2"] = "1.2.0",
@@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Construct an instance.</summary>
/// <param name="version">The game version string.</param>
public GameVersion(string version)
- : base(GameVersion.GetSemanticVersionString(version)) { }
+ : base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { }
/// <summary>Get a string representation of the version.</summary>
public override string ToString()
@@ -53,33 +53,21 @@ namespace StardewModdingAPI.Framework
private static string GetSemanticVersionString(string gameVersion)
{
// mapped version
- if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion))
- return semanticVersion;
-
- // special case: four-part versions
- string[] parts = gameVersion.Split('.');
- if (parts.Length == 4)
- return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}";
-
- return gameVersion;
+ return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
+ ? semanticVersion
+ : gameVersion;
}
/// <summary>Convert a semantic version string to the equivalent game version string.</summary>
/// <param name="semanticVersion">The semantic version string.</param>
private static string GetGameVersionString(string semanticVersion)
{
- // mapped versions
foreach (var mapping in GameVersion.VersionMap)
{
if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase))
return mapping.Key;
}
- // special case: four-part versions
- string[] parts = semanticVersion.Split('.', '+');
- if (parts.Length == 4)
- return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}";
-
return semanticVersion;
}
}
diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs
index c3155b1c..8b45e196 100644
--- a/src/SMAPI/Framework/InternalExtensions.cs
+++ b/src/SMAPI/Framework/InternalExtensions.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Reflection;
+using System.Threading;
using Microsoft.Xna.Framework.Graphics;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Reflection;
@@ -84,6 +85,75 @@ namespace StardewModdingAPI.Framework
}
/****
+ ** ReaderWriterLockSlim
+ ****/
+ /// <summary>Run code within a read lock.</summary>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static void InReadLock(this ReaderWriterLockSlim @lock, Action action)
+ {
+ @lock.EnterReadLock();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ @lock.ExitReadLock();
+ }
+ }
+
+ /// <summary>Run code within a read lock.</summary>
+ /// <typeparam name="TReturn">The action's return value.</typeparam>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static TReturn InReadLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
+ {
+ @lock.EnterReadLock();
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ @lock.ExitReadLock();
+ }
+ }
+
+ /// <summary>Run code within a write lock.</summary>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static void InWriteLock(this ReaderWriterLockSlim @lock, Action action)
+ {
+ @lock.EnterWriteLock();
+ try
+ {
+ action();
+ }
+ finally
+ {
+ @lock.ExitWriteLock();
+ }
+ }
+
+ /// <summary>Run code within a write lock.</summary>
+ /// <typeparam name="TReturn">The action's return value.</typeparam>
+ /// <param name="lock">The lock to set.</param>
+ /// <param name="action">The action to perform.</param>
+ public static TReturn InWriteLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
+ {
+ @lock.EnterWriteLock();
+ try
+ {
+ return action();
+ }
+ finally
+ {
+ @lock.ExitWriteLock();
+ }
+ }
+
+ /****
** Sprite batch
****/
/// <summary>Get whether the sprite batch is between a begin and end pair.</summary>
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index 3d43c539..6cde849c 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -177,7 +177,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
private string GetGlobalDataPath(string key)
{
this.AssertSlug(key, nameof(key));
- return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
+ return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
}
/// <summary>Assert that a key contains only characters that are safe in all contexts.</summary>
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index f996ae97..9139b371 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -158,6 +158,9 @@ namespace StardewModdingAPI.Framework
// init basics
this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
+ if (File.Exists(Constants.ApiUserConfigPath))
+ JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
+
this.LogFile = new LogFileManager(logPath);
this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging)
{
@@ -318,7 +321,7 @@ namespace StardewModdingAPI.Framework
// show details if game crashed during last session
if (File.Exists(Constants.FatalCrashMarker))
{
- this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error);
+ this.Monitor.Log("The game crashed last time you played. If it happens repeatedly, see 'get help' on https://smapi.io.", LogLevel.Error);
this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error);
this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info);
Console.ReadKey();
@@ -600,6 +603,8 @@ namespace StardewModdingAPI.Framework
else
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
+ updateFound = response.SuggestedUpdate?.Version;
+
// show errors
if (response.Errors.Any())
{
diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs
index d4d5df50..2d0efa0d 100644
--- a/src/SMAPI/Framework/SnapshotListDiff.cs
+++ b/src/SMAPI/Framework/SnapshotListDiff.cs
@@ -42,10 +42,12 @@ namespace StardewModdingAPI.Framework
this.IsChanged = isChanged;
this.RemovedImpl.Clear();
- this.RemovedImpl.AddRange(removed);
+ if (removed != null)
+ this.RemovedImpl.AddRange(removed);
this.AddedImpl.Clear();
- this.AddedImpl.AddRange(added);
+ if (added != null)
+ this.AddedImpl.AddRange(added);
}
/// <summary>Update the snapshot.</summary>
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index b86a6790..7a58d52c 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -190,17 +190,9 @@ namespace StardewModdingAPI.Metadata
case "characters\\farmer\\farmer_base": // Farmer
case "characters\\farmer\\farmer_base_bald":
- if (Game1.player == null || !Game1.player.IsMale)
- return false;
- Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
- return true;
-
- case "characters\\farmer\\farmer_girl_base": // Farmer
+ case "characters\\farmer\\farmer_girl_base":
case "characters\\farmer\\farmer_girl_base_bald":
- if (Game1.player == null || Game1.player.IsMale)
- return false;
- Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
- return true;
+ return this.ReloadPlayerSprites(key);
case "characters\\farmer\\hairstyles": // Game1.LoadContent
FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key);
@@ -835,6 +827,27 @@ namespace StardewModdingAPI.Metadata
}
}
+ /// <summary>Reload the sprites for matching players.</summary>
+ /// <param name="key">The asset key to reload.</param>
+ private bool ReloadPlayerSprites(string key)
+ {
+ Farmer[] players =
+ (
+ from player in Game1.getOnlineFarmers()
+ where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture())
+ select player
+ )
+ .ToArray();
+
+ foreach (Farmer player in players)
+ {
+ this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture());
+ player.FarmerRenderer.MarkSpriteDirty();
+ }
+
+ return players.Any();
+ }
+
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
@@ -874,7 +887,11 @@ namespace StardewModdingAPI.Metadata
// update dialogue
foreach (NPC villager in villagers)
+ {
villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue
+ villager.resetCurrentDialogue();
+ }
+
return true;
}
@@ -896,18 +913,16 @@ namespace StardewModdingAPI.Metadata
this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false);
this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null);
villager.Schedule = villager.getSchedule(Game1.dayOfMonth);
- if (villager.Schedule == null)
- {
- this.Monitor.Log($"A mod set an invalid schedule for {villager.Name ?? key}, so the NPC may not behave correctly.", LogLevel.Warn);
- return true;
- }
// switch to new schedule if needed
- int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
- if (lastScheduleTime != 0)
+ if (villager.Schedule != null)
{
- villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
- villager.checkSchedule(lastScheduleTime);
+ int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
+ if (lastScheduleTime != 0)
+ {
+ villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
+ villager.checkSchedule(lastScheduleTime);
+ }
}
}
return true;
diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs
index eedb4164..c16ca7cc 100644
--- a/src/SMAPI/Patches/LoadErrorPatch.cs
+++ b/src/SMAPI/Patches/LoadErrorPatch.cs
@@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Harmony;
+using StardewModdingAPI.Framework.Exceptions;
using StardewModdingAPI.Framework.Patching;
using StardewValley;
+using StardewValley.Buildings;
using StardewValley.Locations;
namespace StardewModdingAPI.Patches
@@ -64,10 +66,24 @@ namespace StardewModdingAPI.Patches
/// <returns>Returns whether to execute the original method.</returns>
private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations)
{
+ bool removedAny =
+ LoadErrorPatch.RemoveInvalidLocations(gamelocations)
+ | LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
+ | LoadErrorPatch.RemoveInvalidNpcs(gamelocations);
+
+ if (removedAny)
+ LoadErrorPatch.OnContentRemoved();
+
+ return true;
+ }
+
+ /// <summary>Remove locations which don't exist in-game.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveInvalidLocations(List<GameLocation> locations)
+ {
bool removedAny = false;
- // remove invalid locations
- foreach (GameLocation location in gamelocations.ToArray())
+ foreach (GameLocation location in locations.ToArray())
{
if (location is Cellar)
continue; // missing cellars will be added by the game code
@@ -75,23 +91,48 @@ namespace StardewModdingAPI.Patches
if (Game1.getLocationFromName(location.name) == null)
{
LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn);
- gamelocations.Remove(location);
+ locations.Remove(location);
removedAny = true;
}
}
- // get building interiors
- var interiors =
- (
- from location in gamelocations.OfType<BuildableGameLocation>()
- from building in location.buildings
- where building.indoors.Value != null
- select building.indoors.Value
- );
+ return removedAny;
+ }
+
+ /// <summary>Remove buildings which don't exist in the game data.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations)
+ {
+ bool removedAny = false;
+
+ foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>())
+ {
+ foreach (Building building in location.buildings.ToArray())
+ {
+ try
+ {
+ BluePrint _ = new BluePrint(building.buildingType.Value);
+ }
+ catch (SContentLoadException)
+ {
+ LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn);
+ location.buildings.Remove(building);
+ removedAny = true;
+ }
+ }
+ }
+
+ return removedAny;
+ }
+
+ /// <summary>Remove NPCs which don't exist in the game data.</summary>
+ /// <param name="locations">The current game locations.</param>
+ private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations)
+ {
+ bool removedAny = false;
- // remove custom NPCs which no longer exist
IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions");
- foreach (GameLocation location in gamelocations.Concat(interiors))
+ foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations))
{
foreach (NPC npc in location.characters.ToArray())
{
@@ -103,7 +144,7 @@ namespace StardewModdingAPI.Patches
}
catch
{
- LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
+ LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
location.characters.Remove(npc);
removedAny = true;
}
@@ -111,10 +152,22 @@ namespace StardewModdingAPI.Patches
}
}
- if (removedAny)
- LoadErrorPatch.OnContentRemoved();
+ return removedAny;
+ }
- return true;
+ /// <summary>Get all locations, including building interiors.</summary>
+ /// <param name="locations">The main game locations.</param>
+ private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations)
+ {
+ foreach (GameLocation location in locations)
+ {
+ yield return location;
+ if (location is BuildableGameLocation buildableLocation)
+ {
+ foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null))
+ yield return interior;
+ }
+ }
}
}
}
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index 824bb783..57b4f885 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -6,6 +6,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes.
+This file is overwritten each time you update or reinstall SMAPI. To avoid losing custom settings,
+create a 'config.user.json' file in the same folder with *only* the settings you want to change.
+That file won't be overwritten, and any settings in it will override the default options. Don't
+copy all the settings, or you may cause bugs due to overridden changes in future SMAPI versions.
+
+
*/
{
diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs
index 2a33ecef..4a175efe 100644
--- a/src/SMAPI/SemanticVersion.cs
+++ b/src/SMAPI/SemanticVersion.cs
@@ -39,18 +39,36 @@ namespace StardewModdingAPI
/// <param name="majorVersion">The major version incremented for major API changes.</param>
/// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
- /// <param name="prerelease">An optional prerelease tag.</param>
- /// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param>
+ /// <param name="prereleaseTag">An optional prerelease tag.</param>
+ /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
+ public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null)
+ : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="majorVersion">The major version incremented for major API changes.</param>
+ /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
+ /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
+ /// <param name="prereleaseTag">An optional prerelease tag.</param>
+ /// <param name="platformRelease">The platform-specific version (if applicable).</param>
+ /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
[JsonConstructor]
- public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null)
- : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { }
+ internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null)
+ : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The semantic version string.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
/// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
public SemanticVersion(string version)
- : this(new Toolkit.SemanticVersion(version)) { }
+ : this(version, allowNonStandard: false) { }
+
+ /// <summary>Construct an instance.</summary>
+ /// <param name="version">The semantic version string.</param>
+ /// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param>
+ /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
+ /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
+ internal SemanticVersion(string version, bool allowNonStandard)
+ : this(new Toolkit.SemanticVersion(version, allowNonStandard)) { }
/// <summary>Construct an instance.</summary>
/// <param name="version">The assembly version.</param>
@@ -141,6 +159,12 @@ namespace StardewModdingAPI
return this.Version.ToString();
}
+ /// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
+ public bool IsNonStandard()
+ {
+ return this.Version.IsNonStandard();
+ }
+
/// <summary>Parse a version string without throwing an exception if it fails.</summary>
/// <param name="version">The version string.</param>
/// <param name="parsed">The parsed representation.</param>