diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-01-22 21:05:04 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2021-01-22 21:05:04 -0500 |
commit | d0dc3ea6f6d03e6aabdab5f5f10a60177f0e53b6 (patch) | |
tree | 02896d970c7650d8f7c8b84f54e53eddab88b4ca /src | |
parent | 5953fc3bd083ae0a579d2da1ad833e6163848086 (diff) | |
parent | 733750fdc4f5d16069d95880144619c0e31e8a89 (diff) | |
download | SMAPI-d0dc3ea6f6d03e6aabdab5f5f10a60177f0e53b6.tar.gz SMAPI-d0dc3ea6f6d03e6aabdab5f5f10a60177f0e53b6.tar.bz2 SMAPI-d0dc3ea6f6d03e6aabdab5f5f10a60177f0e53b6.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src')
101 files changed, 1447 insertions, 404 deletions
diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 035ec382..ff74a659 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -28,7 +28,8 @@ namespace StardewModdingApi.Installer /// <summary>The mod IDs which the installer should allow as bundled mods.</summary> private readonly string[] BundledModIDs = { "SMAPI.SaveBackup", - "SMAPI.ConsoleCommands" + "SMAPI.ConsoleCommands", + "SMAPI.ErrorHandler" }; /// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs index 98daa906..01cab92e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/TrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs @@ -4,8 +4,8 @@ using System.Linq; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands { - /// <summary>The base implementation for a trainer command.</summary> - internal abstract class TrainerCommand : ITrainerCommand + /// <summary>The base implementation for a console command.</summary> + internal abstract class ConsoleCommand : IConsoleCommand { /********* ** Accessors @@ -50,7 +50,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <param name="description">The command description.</param> /// <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) + protected ConsoleCommand(string name, string description, bool mayNeedInput = false, bool mayNeedUpdate = false) { this.Name = name; this.Description = description; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs index d4d36e5d..9c82bbd3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ITrainerCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/IConsoleCommand.cs @@ -1,7 +1,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands { /// <summary>A console command to register.</summary> - internal interface ITrainerCommand + internal interface IConsoleCommand { /********* ** Accessors diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs index 8f59342e..957b0e75 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs @@ -5,7 +5,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which runs one of the game's save migrations.</summary> - internal class ApplySaveFixCommand : TrainerCommand + internal class ApplySaveFixCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs index e4010111..1955c14e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which sends a debug command to the game.</summary> - internal class DebugCommand : TrainerCommand + internal class DebugCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs index 54d27185..27f6ce53 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which shows the data files.</summary> - internal class ShowDataFilesCommand : TrainerCommand + internal class ShowDataFilesCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs index 0257892f..71093184 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs @@ -3,7 +3,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which shows the game files.</summary> - internal class ShowGameFilesCommand : TrainerCommand + internal class ShowGameFilesCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs index 11aa10c3..46583dc3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs @@ -3,7 +3,7 @@ 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 + internal class TestInputCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index 6cb2b624..0e8f7517 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -7,7 +7,7 @@ using Object = StardewValley.Object; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which adds an item to the player inventory.</summary> - internal class AddCommand : TrainerCommand + internal class AddCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs index a835455e..1f12e5f9 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which list item types.</summary> - internal class ListItemTypesCommand : TrainerCommand + internal class ListItemTypesCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 4232ce16..67569298 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -6,7 +6,7 @@ using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which list items available to spawn.</summary> - internal class ListItemsCommand : TrainerCommand + internal class ListItemsCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index f0815ef6..7b7cbf83 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the color of a player feature.</summary> - internal class SetColorCommand : TrainerCommand + internal class SetColorCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index 59bda5dd..f27b336f 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -4,21 +4,14 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current health.</summary> - internal class SetHealthCommand : TrainerCommand + internal class SetHealthCommand : ConsoleCommand { /********* - ** Fields - *********/ - /// <summary>Whether to keep the player's health at its maximum.</summary> - private bool 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.", mayNeedUpdate: true) { } + : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount.") { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -29,36 +22,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player // no-argument mode if (!args.Any()) { - monitor.Log($"You currently have {(this.InfiniteHealth ? "infinite" : Game1.player.health.ToString())} health. Specify a value to change it.", LogLevel.Info); + monitor.Log($"You currently have {Game1.player.health} health. Specify a value to change it.", LogLevel.Info); return; } // handle string amountStr = args[0]; - if (amountStr == "inf") + if (int.TryParse(amountStr, out int amount)) { - this.InfiniteHealth = true; - monitor.Log("OK, you now have infinite health.", LogLevel.Info); + Game1.player.health = amount; + monitor.Log($"OK, you now have {Game1.player.health} health.", LogLevel.Info); } else - { - this.InfiniteHealth = false; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.health = amount; - monitor.Log($"OK, you now have {Game1.player.health} health.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); - } - } - - /// <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) - { - if (this.InfiniteHealth && Context.IsWorldReady) - Game1.player.health = Game1.player.maxHealth; + this.LogArgumentNotInt(monitor); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs index 9c66c4fe..df90adf2 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current immunity.</summary> - internal class SetImmunityCommand : TrainerCommand + internal class SetImmunityCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs index f4ae0694..a5f7f444 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's maximum health.</summary> - internal class SetMaxHealthCommand : TrainerCommand + internal class SetMaxHealthCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index 5bce5ea3..e3c2f011 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's maximum stamina.</summary> - internal class SetMaxStaminaCommand : TrainerCommand + internal class SetMaxStaminaCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index 6e3d68b6..787ce920 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -4,21 +4,14 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current money.</summary> - internal class SetMoneyCommand : TrainerCommand + internal class SetMoneyCommand : ConsoleCommand { /********* - ** Fields - *********/ - /// <summary>Whether to keep the player's money at a set value.</summary> - private bool 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.", mayNeedUpdate: true) { } + : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount.") { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -29,36 +22,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player // validate if (!args.Any()) { - monitor.Log($"You currently have {(this.InfiniteMoney ? "infinite" : Game1.player.Money.ToString())} gold. Specify a value to change it.", LogLevel.Info); + monitor.Log($"You currently have {Game1.player.Money} gold. Specify a value to change it.", LogLevel.Info); return; } // handle string amountStr = args[0]; - if (amountStr == "inf") + if (int.TryParse(amountStr, out int amount)) { - this.InfiniteMoney = true; - monitor.Log("OK, you now have infinite money.", LogLevel.Info); + Game1.player.Money = amount; + monitor.Log($"OK, you now have {Game1.player.Money} gold.", LogLevel.Info); } else - { - this.InfiniteMoney = false; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.Money = amount; - monitor.Log($"OK, you now have {Game1.player.Money} gold.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); - } - } - - /// <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) - { - if (this.InfiniteMoney && Context.IsWorldReady) - Game1.player.Money = 999999; + this.LogArgumentNotInt(monitor); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs index e8cb0584..4911ad1c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs @@ -3,7 +3,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's name.</summary> - internal class SetNameCommand : TrainerCommand + internal class SetNameCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index 60a1dcb1..c78378ef 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -4,21 +4,14 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current stamina.</summary> - internal class SetStaminaCommand : TrainerCommand + internal class SetStaminaCommand : ConsoleCommand { /********* - ** Fields - *********/ - /// <summary>Whether to keep the player's stamina at its maximum.</summary> - private bool 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.", mayNeedUpdate: true) { } + : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount.") { } /// <summary>Handle the command.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> @@ -29,36 +22,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player // validate if (!args.Any()) { - monitor.Log($"You currently have {(this.InfiniteStamina ? "infinite" : Game1.player.Stamina.ToString())} stamina. Specify a value to change it.", LogLevel.Info); + monitor.Log($"You currently have {Game1.player.Stamina} stamina. Specify a value to change it.", LogLevel.Info); return; } // handle string amountStr = args[0]; - if (amountStr == "inf") + if (int.TryParse(amountStr, out int amount)) { - this.InfiniteStamina = true; - monitor.Log("OK, you now have infinite stamina.", LogLevel.Info); + Game1.player.Stamina = amount; + monitor.Log($"OK, you now have {Game1.player.Stamina} stamina.", LogLevel.Info); } else - { - this.InfiniteStamina = false; - if (int.TryParse(amountStr, out int amount)) - { - Game1.player.Stamina = amount; - monitor.Log($"OK, you now have {Game1.player.Stamina} stamina.", LogLevel.Info); - } - else - this.LogArgumentNotInt(monitor); - } - } - - /// <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) - { - if (this.InfiniteStamina && Context.IsWorldReady) - Game1.player.stamina = Game1.player.MaxStamina; + this.LogArgumentNotInt(monitor); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 31f4107d..98f6c330 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -3,7 +3,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits a player style.</summary> - internal class SetStyleCommand : TrainerCommand + internal class SetStyleCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs index 29052be3..4b0e45a0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs @@ -10,7 +10,7 @@ using SObject = StardewValley.Object; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which clears in-game objects.</summary> - internal class ClearCommand : TrainerCommand + internal class ClearCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs index 2cec0fd3..0aa9c9c3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs @@ -4,7 +4,7 @@ using StardewValley.Locations; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which moves the player to the next mine level.</summary> - internal class DownMineLevelCommand : TrainerCommand + internal class DownMineLevelCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs index 736a93a0..16faa2fe 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/FreezeTimeCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which freezes the current time.</summary> - internal class FreezeTimeCommand : TrainerCommand + internal class FreezeTimeCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index 23c266ea..4028b3dc 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -5,7 +5,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current day.</summary> - internal class SetDayCommand : TrainerCommand + internal class SetDayCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs index b4f6d5b3..40f4b19f 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs @@ -4,7 +4,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which moves the player to the given mine level.</summary> - internal class SetMineLevelCommand : TrainerCommand + internal class SetMineLevelCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index 676369fe..a4cb35bb 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -5,7 +5,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current season.</summary> - internal class SetSeasonCommand : TrainerCommand + internal class SetSeasonCommand : ConsoleCommand { /********* ** Fields diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index 9eae6741..6782e38a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -5,7 +5,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current time.</summary> - internal class SetTimeCommand : TrainerCommand + internal class SetTimeCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index 648830c1..95401962 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -5,7 +5,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current year.</summary> - internal class SetYearCommand : TrainerCommand + internal class SetYearCommand : ConsoleCommand { /********* ** Public methods diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index 5c4f3bba..91437fd3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -13,13 +13,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands ** Fields *********/ /// <summary>The commands to handle.</summary> - private ITrainerCommand[] Commands; + private IConsoleCommand[] Commands; /// <summary>The commands which may need to handle update ticks.</summary> - private ITrainerCommand[] UpdateHandlers; + private IConsoleCommand[] UpdateHandlers; /// <summary>The commands which may need to handle input.</summary> - private ITrainerCommand[] InputHandlers; + private IConsoleCommand[] InputHandlers; /********* @@ -31,7 +31,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands { // register commands this.Commands = this.ScanForCommands().ToArray(); - foreach (ITrainerCommand command in this.Commands) + foreach (IConsoleCommand command in this.Commands) helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); // cache commands @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <param name="e">The event arguments.</param> private void OnButtonPressed(object sender, ButtonPressedEventArgs e) { - foreach (ITrainerCommand command in this.InputHandlers) + foreach (IConsoleCommand command in this.InputHandlers) command.OnButtonPressed(this.Monitor, e.Button); } @@ -61,7 +61,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <param name="e">The event arguments.</param> private void OnUpdateTicked(object sender, EventArgs e) { - foreach (ITrainerCommand command in this.UpdateHandlers) + foreach (IConsoleCommand command in this.UpdateHandlers) command.OnUpdated(this.Monitor); } @@ -69,19 +69,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <param name="command">The command to invoke.</param> /// <param name="commandName">The command name specified by the user.</param> /// <param name="args">The command arguments.</param> - private void HandleCommand(ITrainerCommand command, string commandName, string[] args) + private void HandleCommand(IConsoleCommand command, string commandName, string[] args) { ArgumentParser argParser = new ArgumentParser(commandName, args, this.Monitor); command.Handle(this.Monitor, commandName, argParser); } /// <summary>Find all commands in the assembly.</summary> - private IEnumerable<ITrainerCommand> ScanForCommands() + private IEnumerable<IConsoleCommand> ScanForCommands() { return ( from type in this.GetType().Assembly.GetTypes() - where !type.IsAbstract && typeof(ITrainerCommand).IsAssignableFrom(type) - select (ITrainerCommand)Activator.CreateInstance(type) + where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type) + select (IConsoleCommand)Activator.CreateInstance(type) ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index a7daf62b..f2340638 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.8.4", + "Version": "3.9.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.8.4" + "MinimumApiVersion": "3.9.0" } diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs new file mode 100644 index 00000000..2f6f1939 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Mods.ErrorHandler.Patches; +using StardewValley; + +namespace StardewModdingAPI.Mods.ErrorHandler +{ + /// <summary>The main entry point for the mod.</summary> + public class ModEntry : Mod + { + /********* + ** Private methods + *********/ + /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> + private bool IsSaveContentRemoved; + + + /********* + ** Public methods + *********/ + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + public override void Entry(IModHelper helper) + { + // get SMAPI core types + SCore core = SCore.Instance; + LogManager logManager = core.GetType().GetField("LogManager", BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(core) as LogManager; + if (logManager == null) + { + this.Monitor.Log($"Can't access SMAPI's internal log manager. Error-handling patches won't be applied.", LogLevel.Error); + return; + } + + // apply patches + new GamePatcher(this.Monitor).Apply( + new EventErrorPatch(logManager.MonitorForGame), + new DialogueErrorPatch(logManager.MonitorForGame, this.Helper.Reflection), + new ObjectErrorPatch(), + new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved), + new ScheduleErrorPatch(logManager.MonitorForGame), + new UtilityErrorPatches() + ); + + // hook events + this.Helper.Events.GameLoop.SaveLoaded += this.OnSaveLoaded; + } + + + /********* + ** Private methods + *********/ + /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary> + internal void OnSaveContentRemoved() + { + this.IsSaveContentRemoved = true; + } + + /// <summary>The method invoked when a save is loaded.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + public void OnSaveLoaded(object sender, SaveLoadedEventArgs e) + { + // show in-game warning for removed save content + if (this.IsSaveContentRemoved) + { + this.IsSaveContentRemoved = false; + Game1.addHUDMessage(new HUDMessage(this.Helper.Translation.Get("warn.invalid-content-removed"), HUDMessage.error_type)); + } + } + } +} diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs index 215df561..ba0ca582 100644 --- a/src/SMAPI/Patches/DialogueErrorPatch.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/DialogueErrorPatch.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework.Patching; -using StardewModdingAPI.Framework.Reflection; using StardewValley; #if HARMONY_2 using HarmonyLib; @@ -12,7 +11,7 @@ using System.Reflection; using Harmony; #endif -namespace StardewModdingAPI.Patches +namespace StardewModdingAPI.Mods.ErrorHandler.Patches { /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> @@ -27,7 +26,7 @@ namespace StardewModdingAPI.Patches private static IMonitor MonitorForGame; /// <summary>Simplifies access to private code.</summary> - private static Reflector Reflection; + private static IReflectionHelper Reflection; /********* @@ -43,7 +42,7 @@ namespace StardewModdingAPI.Patches /// <summary>Construct an instance.</summary> /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> /// <param name="reflector">Simplifies access to private code.</param> - public DialogueErrorPatch(IMonitor monitorForGame, Reflector reflector) + public DialogueErrorPatch(IMonitor monitorForGame, IReflectionHelper reflector) { DialogueErrorPatch.MonitorForGame = monitorForGame; DialogueErrorPatch.Reflection = reflector; @@ -167,7 +166,7 @@ namespace StardewModdingAPI.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, MethodInfo __originalMethod) { - const string key = nameof(Before_NPC_CurrentDialogue); + const string key = nameof(DialogueErrorPatch.Before_NPC_CurrentDialogue); if (!PatchHelper.StartIntercept(key)) return true; diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/EventErrorPatch.cs index 46651387..fabc6cad 100644 --- a/src/SMAPI/Patches/EventErrorPatch.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/EventErrorPatch.cs @@ -9,7 +9,7 @@ using Harmony; using StardewModdingAPI.Framework.Patching; using StardewValley; -namespace StardewModdingAPI.Patches +namespace StardewModdingAPI.Mods.ErrorHandler.Patches { /// <summary>A Harmony patch for <see cref="GameLocation.checkEventPrecondition"/> which intercepts invalid preconditions and logs an error instead of crashing.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> @@ -89,7 +89,7 @@ namespace StardewModdingAPI.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_GameLocation_CheckEventPrecondition(GameLocation __instance, ref int __result, string precondition, MethodInfo __originalMethod) { - const string key = nameof(Before_GameLocation_CheckEventPrecondition); + const string key = nameof(EventErrorPatch.Before_GameLocation_CheckEventPrecondition); if (!PatchHelper.StartIntercept(key)) return true; diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs index f5ee5d71..2227ea07 100644 --- a/src/SMAPI/Patches/LoadErrorPatch.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/LoadErrorPatch.cs @@ -13,7 +13,7 @@ using StardewValley; using StardewValley.Buildings; using StardewValley.Locations; -namespace StardewModdingAPI.Patches +namespace StardewModdingAPI.Mods.ErrorHandler.Patches { /// <summary>A Harmony patch for <see cref="SaveGame"/> which prevents some errors due to broken save data.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs index 64b8e6b6..70f054cd 100644 --- a/src/SMAPI/Patches/ObjectErrorPatch.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectErrorPatch.cs @@ -12,7 +12,7 @@ using System.Reflection; using Harmony; #endif -namespace StardewModdingAPI.Patches +namespace StardewModdingAPI.Mods.ErrorHandler.Patches { /// <summary>A Harmony patch for <see cref="SObject.getDescription"/> which intercepts crashes due to the item no longer existing.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> @@ -103,7 +103,7 @@ namespace StardewModdingAPI.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_Object_loadDisplayName(SObject __instance, ref string __result, MethodInfo __originalMethod) { - const string key = nameof(Before_Object_loadDisplayName); + const string key = nameof(ObjectErrorPatch.Before_Object_loadDisplayName); if (!PatchHelper.StartIntercept(key)) return true; diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs index 1d58a292..abbd1a8f 100644 --- a/src/SMAPI/Patches/ScheduleErrorPatch.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/ScheduleErrorPatch.cs @@ -11,7 +11,7 @@ using System.Reflection; using Harmony; #endif -namespace StardewModdingAPI.Patches +namespace StardewModdingAPI.Mods.ErrorHandler.Patches { /// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary> /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> @@ -90,7 +90,7 @@ namespace StardewModdingAPI.Patches /// <returns>Returns whether to execute the original method.</returns> private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod) { - const string key = nameof(Before_NPC_parseMasterSchedule); + const string key = nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule); if (!PatchHelper.StartIntercept(key)) return true; diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs new file mode 100644 index 00000000..481c881e --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityErrorPatches.cs @@ -0,0 +1,96 @@ +#if HARMONY_2 +using System; +using HarmonyLib; +#else +using Harmony; +#endif +using System; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using StardewModdingAPI.Framework.Patching; +using StardewValley; + +namespace StardewModdingAPI.Mods.ErrorHandler.Patches +{ + /// <summary>A Harmony patch for <see cref="Utility"/> methods to log more detailed errors.</summary> + /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] + internal class UtilityErrorPatches : IHarmonyPatch + { + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public string Name => nameof(UtilityErrorPatches); + + + /********* + ** Public methods + *********/ + /// <inheritdoc /> +#if HARMONY_2 + public void Apply(Harmony harmony) + { + harmony.Patch( + original: AccessTools.Method(typeof(Utility), nameof(Utility.getItemFromStandardTextDescription)), + finalizer: new HarmonyMethod(this.GetType(), nameof(UtilityErrorPatches.Finalize_Utility_GetItemFromStandardTextDescription)) + ); + } +#else + public void Apply(HarmonyInstance harmony) + { + harmony.Patch( + original: AccessTools.Method(typeof(Utility), nameof(Utility.getItemFromStandardTextDescription)), + prefix: new HarmonyMethod(this.GetType(), nameof(UtilityErrorPatches.Before_Utility_GetItemFromStandardTextDescription)) + ); + } +#endif + + + /********* + ** Private methods + *********/ +#if HARMONY_2 + /// <summary>The method to call instead of <see cref="Utility.getItemFromStandardTextDescription"/>.</summary> + /// <param name="description">The item text description to parse.</param> + /// <param name="delimiter">The delimiter by which to split the text description.</param> + /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> + /// <returns>Returns the exception to throw, if any.</returns> + private static Exception Finalize_Utility_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception __exception) + { + return __exception != null + ? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception) + : null; + } +#else + /// <summary>The method to call instead of <see cref="Utility.getItemFromStandardTextDescription"/>.</summary> + /// <param name="__result">The return value of the original method.</param> + /// <param name="description">The item text description to parse.</param> + /// <param name="who">The player for which the item is being parsed.</param> + /// <param name="delimiter">The delimiter by which to split the text description.</param> + /// <param name="__originalMethod">The method being wrapped.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_Utility_GetItemFromStandardTextDescription(ref Item __result, string description, Farmer who, char delimiter, MethodInfo __originalMethod) + { + const string key = nameof(UtilityErrorPatches.Before_Utility_GetItemFromStandardTextDescription); + if (!PatchHelper.StartIntercept(key)) + return true; + + try + { + __result = (Item)__originalMethod.Invoke(null, new object[] { description, who, delimiter }); + return false; + } + catch (TargetInvocationException ex) + { + throw new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", ex.InnerException); + } + finally + { + PatchHelper.StopIntercept(key); + } + } +#endif + } +} diff --git a/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj new file mode 100644 index 00000000..5c0cf952 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/SMAPI.Mods.ErrorHandler.csproj @@ -0,0 +1,46 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <AssemblyName>ErrorHandler</AssemblyName> + <RootNamespace>StardewModdingAPI.Mods.ErrorHandler</RootNamespace> + <TargetFramework>net45</TargetFramework> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <PlatformTarget>x86</PlatformTarget> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\SMAPI\SMAPI.csproj" Private="False" /> + <Reference Include="..\..\build\0Harmony.dll" Private="False" /> + </ItemGroup> + + <ItemGroup> + <Reference Include="$(GameExecutableName)" HintPath="$(GamePath)\$(GameExecutableName).exe" Private="False" /> + </ItemGroup> + + <Choose> + <!-- Windows --> + <When Condition="$(OS) == 'Windows_NT'"> + <ItemGroup> + <Reference Include="Netcode" HintPath="$(GamePath)\Netcode.dll" Private="False" /> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86" Private="False" /> + </ItemGroup> + </When> + + <!-- Linux/Mac --> + <Otherwise> + <ItemGroup> + <Reference Include="MonoGame.Framework" HintPath="$(GamePath)\MonoGame.Framework.dll" Private="False" /> + </ItemGroup> + </Otherwise> + </Choose> + + <ItemGroup> + <None Update="i18n\*.json" CopyToOutputDirectory="PreserveNewest" /> + <None Update="manifest.json" CopyToOutputDirectory="PreserveNewest" /> + </ItemGroup> + + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <Import Project="..\..\build\common.targets" /> +</Project> diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/de.json b/src/SMAPI.Mods.ErrorHandler/i18n/de.json new file mode 100644 index 00000000..1de6301c --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/de.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/default.json b/src/SMAPI.Mods.ErrorHandler/i18n/default.json new file mode 100644 index 00000000..b74dcea0 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/default.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/es.json b/src/SMAPI.Mods.ErrorHandler/i18n/es.json new file mode 100644 index 00000000..8ba10b70 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/es.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/fr.json b/src/SMAPI.Mods.ErrorHandler/i18n/fr.json new file mode 100644 index 00000000..76978526 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/fr.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/hu.json b/src/SMAPI.Mods.ErrorHandler/i18n/hu.json new file mode 100644 index 00000000..92aca7d0 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/hu.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/it.json b/src/SMAPI.Mods.ErrorHandler/i18n/it.json new file mode 100644 index 00000000..5182972e --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/it.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ja.json b/src/SMAPI.Mods.ErrorHandler/i18n/ja.json new file mode 100644 index 00000000..559c7fbe --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/ja.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)" +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ko.json b/src/SMAPI.Mods.ErrorHandler/i18n/ko.json new file mode 100644 index 00000000..48f05c26 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/ko.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "충돌을 방지하기 위해 잘못된 컨텐츠가 제거되었습니다 (자세한 내용은 SMAPI 콘솔 참조)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/pt.json b/src/SMAPI.Mods.ErrorHandler/i18n/pt.json new file mode 100644 index 00000000..8ea8cec9 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/pt.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/ru.json b/src/SMAPI.Mods.ErrorHandler/i18n/ru.json new file mode 100644 index 00000000..e9c3b313 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/ru.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)" +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/tr.json b/src/SMAPI.Mods.ErrorHandler/i18n/tr.json new file mode 100644 index 00000000..a05ab152 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/tr.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)." +} diff --git a/src/SMAPI.Mods.ErrorHandler/i18n/zh.json b/src/SMAPI.Mods.ErrorHandler/i18n/zh.json new file mode 100644 index 00000000..e959aa40 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/i18n/zh.json @@ -0,0 +1,4 @@ +{ + // warning messages + "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)" +} diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json new file mode 100644 index 00000000..bc0a7294 --- /dev/null +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -0,0 +1,9 @@ +{ + "Name": "Error Handler", + "Author": "SMAPI", + "Version": "3.9.0", + "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", + "UniqueID": "SMAPI.ErrorHandler", + "EntryDll": "ErrorHandler.dll", + "MinimumApiVersion": "3.9.0" +} diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 0fd202da..79727fad 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.8.4", + "Version": "3.9.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.8.4" + "MinimumApiVersion": "3.9.0" } diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs new file mode 100644 index 00000000..0bd6ec17 --- /dev/null +++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Utilities; + +namespace SMAPI.Tests.Utilities +{ + /// <summary>Unit tests for <see cref="KeybindList"/>.</summary> + [TestFixture] + internal class KeybindListTests + { + /********* + ** Unit tests + *********/ + /**** + ** TryParse + ****/ + /// <summary>Assert the parsed fields when constructed from a simple single-key string.</summary> + [TestCaseSource(nameof(KeybindListTests.GetAllButtons))] + public void TryParse_SimpleValue(SButton button) + { + // act + bool success = KeybindList.TryParse($"{button}", out KeybindList parsed, out string[] errors); + + // assert + Assert.IsTrue(success, "Parsing unexpectedly failed."); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.AreEqual(parsed.ToString(), $"{button}"); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + } + + /// <summary>Assert the parsed fields when constructed from multi-key values.</summary> + [TestCase("", ExpectedResult = "None")] + [TestCase(" ", ExpectedResult = "None")] + [TestCase(null, ExpectedResult = "None")] + [TestCase("A + B", ExpectedResult = "A + B")] + [TestCase("A+B", ExpectedResult = "A + B")] + [TestCase(" A+ B ", ExpectedResult = "A + B")] + [TestCase("a +b", ExpectedResult = "A + B")] + [TestCase("a +b, LEFTcontrol + leftALT + LeftSHifT + delete", ExpectedResult = "A + B, LeftControl + LeftAlt + LeftShift + Delete")] + + [TestCase(",", ExpectedResult = "None")] + [TestCase("A,", ExpectedResult = "A")] + [TestCase(",A", ExpectedResult = "A")] + public string TryParse_MultiValues(string input) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + + // assert + Assert.IsTrue(success, "Parsing unexpectedly failed."); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + return parsed.ToString(); + } + + /// <summary>Assert invalid values are rejected.</summary> + [TestCase("+", "Invalid empty button value")] + [TestCase("A+", "Invalid empty button value")] + [TestCase("+C", "Invalid empty button value")] + [TestCase("A + B +, C", "Invalid empty button value")] + [TestCase("A, TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + [TestCase("A + TotallyInvalid", "Invalid button value 'TotallyInvalid'")] + public void TryParse_InvalidValues(string input, string expectedError) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + + // assert + Assert.IsFalse(success, "Parsing unexpectedly succeeded."); + Assert.IsNull(parsed, "The parsed result should be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.AreEqual(expectedError, string.Join("; ", errors), "The errors don't match the expected ones."); + } + + + /**** + ** GetState + ****/ + /// <summary>Assert that <see cref="KeybindList.GetState"/> returns the expected result for a given input state.</summary> + // single value + [TestCase("A", "A:Held", ExpectedResult = SButtonState.Held)] + [TestCase("A", "A:Pressed", ExpectedResult = SButtonState.Pressed)] + [TestCase("A", "A:Released", ExpectedResult = SButtonState.Released)] + [TestCase("A", "A:None", ExpectedResult = SButtonState.None)] + + // multiple values + [TestCase("A + B + C, D", "A:Released, B:None, C:None, D:Pressed", ExpectedResult = SButtonState.Pressed)] // right pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Held, C:Pressed, D:None", ExpectedResult = SButtonState.Pressed)] // left pressed => pressed + [TestCase("A + B + C, D", "A:Pressed, B:Pressed, C:Released, D:None", ExpectedResult = SButtonState.None)] // one key released but other keys weren't down last tick => none + [TestCase("A + B + C, D", "A:Held, B:Held, C:Released, D:None", ExpectedResult = SButtonState.Released)] // all three keys were down last tick and now one is released => released + + // transitive + [TestCase("A, B", "A: Released, B: Pressed", ExpectedResult = SButtonState.Held)] + public SButtonState GetState(string input, string stateMap) + { + // act + bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + if (success && parsed?.Keybinds != null) + { + foreach (var keybind in parsed.Keybinds) +#pragma warning disable 618 // method is marked obsolete because it should only be used in unit tests + keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap); +#pragma warning restore 618 + } + + // assert + Assert.IsTrue(success, "Parsing unexpected failed"); + Assert.IsNotNull(parsed, "The parsed result should not be null."); + Assert.IsNotNull(errors, message: "The errors should never be null."); + Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); + return parsed.GetState(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get all defined buttons.</summary> + private static IEnumerable<SButton> GetAllButtons() + { + foreach (SButton button in Enum.GetValues(typeof(SButton))) + yield return button; + } + + /// <summary>Get the button state defined by a mapping string.</summary> + /// <param name="button">The button to check.</param> + /// <param name="stateMap">The state map.</param> + private SButtonState GetStateFromMap(SButton button, string stateMap) + { + foreach (string rawPair in stateMap.Split(',')) + { + // parse values + string[] parts = rawPair.Split(new[] { ':' }, 2); + if (!Enum.TryParse(parts[0], ignoreCase: true, out SButton curButton)) + Assert.Fail($"The state map is invalid: unknown button value '{parts[0].Trim()}'"); + if (!Enum.TryParse(parts[1], ignoreCase: true, out SButtonState state)) + Assert.Fail($"The state map is invalid: unknown state value '{parts[1].Trim()}'"); + + // get state + if (curButton == button) + return state; + } + + Assert.Fail($"The state map doesn't define button value '{button}'."); + return SButtonState.None; + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index d4c82180..785daba3 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -90,14 +90,6 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning case Platform.Windows: { - // Windows - foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) - { - yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; - yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; - yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; - } - // Windows registry #if SMAPI_FOR_WINDOWS IDictionary<string, string> registryKeys = new Dictionary<string, string> @@ -113,10 +105,19 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning } // via Steam library path - string steampath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); - if (steampath != null) - yield return Path.Combine(steampath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); + string steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); + if (steamPath != null) + yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); #endif + + // default paths + foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) + { + yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Games\Stardew Valley"; + yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; + } } break; diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs index b10510ff..f1e782b6 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs @@ -9,6 +9,9 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The folder is empty or contains only ignored files.</summary> EmptyFolder, + /// <summary>The folder is an empty folder managed by Vortex.</summary> + EmptyVortexFolder, + /// <summary>The folder is ignored by convention.</summary> IgnoredFolder, diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index 5eacee9e..86a97016 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -58,20 +58,28 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning ".lnk" }; - /// <summary>The extensions for files which an XNB mod may contain. If a mod doesn't have a <c>manifest.json</c> and contains *only* these file extensions, it should be considered an XNB mod.</summary> - private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + /// <summary>The extensions for packed content files.</summary> + private readonly HashSet<string> StrictXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { - // XNB files ".xgs", ".xnb", ".xsb", - ".xwb", + ".xwb" + }; - // unpacking artifacts + /// <summary>The extensions for files which an XNB mod may contain, in addition to <see cref="StrictXnbModExtensions"/>.</summary> + private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + { ".json", ".yaml" }; + /// <summary>The name of the marker file added by Vortex to indicate it's managing the folder.</summary> + private readonly string VortexMarkerFileName = "__folder_managed_by_vortex"; + + /// <summary>The name for a mod's configuration JSON file.</summary> + private readonly string ConfigFileName = "config.json"; + /********* ** Public methods @@ -111,18 +119,24 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning // set appropriate invalid-mod error if (manifestFile == null) { - FileInfo[] files = this.RecursivelyGetRelevantFiles(searchFolder).ToArray(); + FileInfo[] files = this.RecursivelyGetFiles(searchFolder).ToArray(); + FileInfo[] relevantFiles = files.Where(this.IsRelevant).ToArray(); + + // empty Vortex folder + // (this filters relevant files internally so it can check for the normally-ignored Vortex marker file) + if (this.IsEmptyVortexFolder(files)) + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyVortexFolder, "it's an empty Vortex folder (is the mod disabled in Vortex?)."); // empty folder - if (!files.Any()) + if (!relevantFiles.Any()) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); // XNB mod - if (files.All(this.IsPotentialXnbFile)) + if (this.IsXnbMod(relevantFiles)) return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); // SMAPI installer - if (files.Any(p => p.Name == "install on Linux.sh" || p.Name == "install on Mac.command" || p.Name == "install on Windows.bat")) + if (relevantFiles.Any(p => p.Name == "install on Linux.sh" || p.Name == "install on Mac.command" || p.Name == "install on Windows.bat")) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file)."); // not a mod? @@ -270,13 +284,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return subfolders.Any() && !files.Any(); } - /// <summary>Recursively get all relevant files in a folder based on the result of <see cref="IsRelevant"/>.</summary> + /// <summary>Recursively get all files in a folder.</summary> /// <param name="folder">The root folder to search.</param> - private IEnumerable<FileInfo> RecursivelyGetRelevantFiles(DirectoryInfo folder) + private IEnumerable<FileInfo> RecursivelyGetFiles(DirectoryInfo folder) { foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) { - if (!this.IsRelevant(entry)) + if (entry is DirectoryInfo && !this.IsRelevant(entry)) continue; if (entry is FileInfo file) @@ -284,7 +298,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning if (entry is DirectoryInfo subfolder) { - foreach (FileInfo subfolderFile in this.RecursivelyGetRelevantFiles(subfolder)) + foreach (FileInfo subfolderFile in this.RecursivelyGetFiles(subfolder)) yield return subfolderFile; } } @@ -302,14 +316,46 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return !this.IgnoreFilesystemNames.Any(p => p.IsMatch(entry.Name)); } - /// <summary>Get whether a file is potentially part of an XNB mod.</summary> - /// <param name="entry">The file.</param> - private bool IsPotentialXnbFile(FileInfo entry) + /// <summary>Get whether a set of files looks like an XNB mod.</summary> + /// <param name="files">The files in the mod.</param> + private bool IsXnbMod(IEnumerable<FileInfo> files) { - if (!this.IsRelevant(entry)) - return true; + bool hasXnbFile = false; + + foreach (FileInfo file in files.Where(this.IsRelevant)) + { + if (this.StrictXnbModExtensions.Contains(file.Extension)) + { + hasXnbFile = true; + continue; + } + + if (!this.PotentialXnbModExtensions.Contains(file.Extension)) + return false; + } + + return hasXnbFile; + } + + /// <summary>Get whether a set of files looks like an XNB mod.</summary> + /// <param name="files">The files in the mod.</param> + private bool IsEmptyVortexFolder(IEnumerable<FileInfo> files) + { + bool hasVortexMarker = false; + + foreach (FileInfo file in files) + { + if (file.Name == this.VortexMarkerFileName) + { + hasVortexMarker = true; + continue; + } + + if (this.IsRelevant(file) && file.Name != this.ConfigFileName) + return false; + } - return this.PotentialXnbModExtensions.Contains(entry.Extension); // use EndsWith to handle cases like image..png + return hasVortexMarker; } /// <summary>Strip newlines from a string.</summary> diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index d58dce0c..2f3e282b 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -293,12 +293,12 @@ namespace StardewModdingAPI.Toolkit return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.OrdinalIgnoreCase); } - return CompareToRaw() switch - { - (< 0) => curOlder, - (> 0) => curNewer, - _ => same - }; + int result = CompareToRaw(); + if (result < 0) + return curOlder; + if (result > 0) + return curNewer; + return same; } /// <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 3604956b..cf69104d 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters ** Accessors *********/ /// <summary>Get whether this converter can read JSON.</summary> - public override bool CanRead => true; + public override bool CanRead { get; } = true; /// <summary>Get whether this converter can write JSON.</summary> - public override bool CanWrite => true; + public override bool CanWrite { get; } = true; /********* diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index 685b515b..0b265201 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -99,7 +99,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$", + "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)$", "@errorMessages": { "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." } diff --git a/src/SMAPI.sln b/src/SMAPI.sln index b7a84fe4..58228ce9 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -61,6 +61,7 @@ EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Installer", "SMAPI.Installer\SMAPI.Installer.csproj", "{0A9BB24F-15FF-4C26-B1A2-81F7AE316518}" ProjectSection(ProjectDependencies) = postProject {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} = {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {CD53AD6F-97F4-4872-A212-50C2A0FD3601} {E6DA2198-7686-4F1D-B312-4A4DC70884C0} = {E6DA2198-7686-4F1D-B312-4A4DC70884C0} EndProjectSection @@ -71,6 +72,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyz EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\SMAPI.Mods.ConsoleCommands.csproj", "{0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.ErrorHandler", "SMAPI.Mods.ErrorHandler\SMAPI.Mods.ErrorHandler.csproj", "{491E775B-EAD0-44D4-B6CA-F1FC3E316D33}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.SaveBackup", "SMAPI.Mods.SaveBackup\SMAPI.Mods.SaveBackup.csproj", "{CD53AD6F-97F4-4872-A212-50C2A0FD3601}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit", "SMAPI.Toolkit\SMAPI.Toolkit.csproj", "{08184F74-60AD-4EEE-A78C-F4A35ADE6246}" @@ -83,6 +86,7 @@ Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{0a9bb24f-15ff-4c26-b1a2-81f7ae316518}*SharedItemsImports = 5 + SMAPI.Internal\SMAPI.Internal.projitems*{491e775b-ead0-44d4-b6ca-f1fc3e316d33}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{80efd92f-728f-41e0-8a5b-9f6f49a91899}*SharedItemsImports = 5 SMAPI.Internal\SMAPI.Internal.projitems*{85208f8d-6fd1-4531-be05-7142490f59fe}*SharedItemsImports = 13 SMAPI.Internal\SMAPI.Internal.projitems*{cd53ad6f-97f4-4872-a212-50c2a0fd3601}*SharedItemsImports = 5 @@ -121,6 +125,10 @@ Global {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Debug|Any CPU.Build.0 = Debug|Any CPU {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.ActiveCfg = Release|Any CPU {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.Build.0 = Release|Any CPU + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Debug|Any CPU.Build.0 = Debug|Any CPU + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Release|Any CPU.ActiveCfg = Release|Any CPU + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33}.Release|Any CPU.Build.0 = Release|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.Build.0 = Debug|Any CPU {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -151,6 +159,7 @@ Global {680B2641-81EA-467C-86A5-0E81CDC57ED0} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {AA95884B-7097-476E-92C8-D0500DE9D6D1} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} + {491E775B-EAD0-44D4-B6CA-F1FC3E316D33} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {AE9A4D46-E910-4293-8BC4-673F85FFF6A5} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 76e863cc..29d4ade5 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -39,6 +39,8 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=Hangfire/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=initializers/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Junimo/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Keybind/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=keybinds/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=modder/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=modders/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Mongo/@EntryIndexedValue">True</s:Boolean> diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index b72471ca..2adafbbf 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -54,10 +54,10 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.8.4"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.9.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.3"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.4"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; diff --git a/src/SMAPI/Events/ButtonsChangedEventArgs.cs b/src/SMAPI/Events/ButtonsChangedEventArgs.cs new file mode 100644 index 00000000..dda41692 --- /dev/null +++ b/src/SMAPI/Events/ButtonsChangedEventArgs.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments when any buttons were pressed or released.</summary> + public class ButtonsChangedEventArgs : EventArgs + { + /********* + ** Fields + *********/ + /// <summary>The buttons that were pressed, held, or released since the previous tick.</summary> + private readonly Lazy<Dictionary<SButtonState, SButton[]>> ButtonsByState; + + + /********* + ** Accessors + *********/ + /// <summary>The current cursor position.</summary> + public ICursorPosition Cursor { get; } + + /// <summary>The buttons which were pressed since the previous tick.</summary> + public IEnumerable<SButton> Pressed => this.ButtonsByState.Value[SButtonState.Pressed]; + + /// <summary>The buttons which were held since the previous tick.</summary> + public IEnumerable<SButton> Held => this.ButtonsByState.Value[SButtonState.Held]; + + /// <summary>The buttons which were released since the previous tick.</summary> + public IEnumerable<SButton> Released => this.ButtonsByState.Value[SButtonState.Released]; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="cursor">The cursor position.</param> + /// <param name="inputState">The game's current input state.</param> + internal ButtonsChangedEventArgs(ICursorPosition cursor, SInputState inputState) + { + this.Cursor = cursor; + this.ButtonsByState = new Lazy<Dictionary<SButtonState, SButton[]>>(() => this.GetButtonsByState(inputState)); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the buttons that were pressed, held, or released since the previous tick.</summary> + /// <param name="inputState">The game's current input state.</param> + private Dictionary<SButtonState, SButton[]> GetButtonsByState(SInputState inputState) + { + Dictionary<SButtonState, SButton[]> lookup = inputState.ButtonStates + .GroupBy(p => p.Value) + .ToDictionary(p => p.Key, p => p.Select(p => p.Key).ToArray()); + + foreach (var state in new[] { SButtonState.Pressed, SButtonState.Held, SButtonState.Released }) + { + if (!lookup.ContainsKey(state)) + lookup[state] = new SButton[0]; + } + + return lookup; + } + } +} diff --git a/src/SMAPI/Events/IInputEvents.cs b/src/SMAPI/Events/IInputEvents.cs index 5c40a438..081c40c0 100644 --- a/src/SMAPI/Events/IInputEvents.cs +++ b/src/SMAPI/Events/IInputEvents.cs @@ -5,6 +5,9 @@ namespace StardewModdingAPI.Events /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> public interface IInputEvents { + /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary> + event EventHandler<ButtonsChangedEventArgs> ButtonsChanged; + /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> event EventHandler<ButtonPressedEventArgs> ButtonPressed; diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index 3db3856f..665c019b 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -414,7 +414,7 @@ namespace StardewModdingAPI.Framework.ContentManagers int loadedIndex = this.TryFindTilesheet(loadedMap, vanillaSheet.Id); string reason = loadedIndex != -1 - ? $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewcommunitywiki.com/Modding:Maps#Tilesheet_order for help." + ? $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help." : $"mod has no tilesheet with ID '{vanillaSheet.Id}'. Map replacements must keep the original tilesheets to avoid errors or crashes."; SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 753ec188..1456d3c1 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -127,7 +127,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (asset is Map map) { map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName); + this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true); } } break; @@ -168,7 +168,7 @@ namespace StardewModdingAPI.Framework.ContentManagers FormatManager formatManager = FormatManager.Instance; Map map = formatManager.LoadMap(file.FullName); map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName); + this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false); asset = (T)(object)map; } break; @@ -260,8 +260,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> /// <param name="map">The map whose tilesheets to fix.</param> /// <param name="relativeMapPath">The relative map path within the mod folder.</param> + /// <param name="fixEagerPathPrefixes">Whether to undo the game's eager tilesheet path prefixing for maps loaded from an <c>.xnb</c> file, which incorrectly prefixes tilesheet paths with the map's local asset key folder.</param> /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> - private void FixTilesheetPaths(Map map, string relativeMapPath) + private void FixTilesheetPaths(Map map, string relativeMapPath, bool fixEagerPathPrefixes) { // get map info relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators @@ -270,12 +271,16 @@ namespace StardewModdingAPI.Framework.ContentManagers // fix tilesheets foreach (TileSheet tilesheet in map.TileSheets) { + // get image source tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource); - string imageSource = tilesheet.ImageSource; - string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; + + // reverse incorrect eager tilesheet path prefixing + if (fixEagerPathPrefixes && relativeMapFolder.Length > 0 && imageSource.StartsWith(relativeMapFolder)) + imageSource = imageSource.Substring(relativeMapFolder.Length + 1); // validate tilesheet path + string errorPrefix = $"{this.ModName} loaded map '{relativeMapPath}' with invalid tilesheet path '{imageSource}'."; if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) throw new SContentLoadException($"{errorPrefix} Tilesheet paths must be a relative path without directory climbing (../)."); diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 665dbfe3..f4abfffe 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -93,6 +93,9 @@ namespace StardewModdingAPI.Framework.Events /**** ** Input ****/ + /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary> + public readonly ManagedEvent<ButtonsChangedEventArgs> ButtonsChanged; + /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed; @@ -212,6 +215,7 @@ namespace StardewModdingAPI.Framework.Events this.TimeChanged = ManageEventOf<TimeChangedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.TimeChanged)); this.ReturnedToTitle = ManageEventOf<ReturnedToTitleEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.ReturnedToTitle)); + this.ButtonsChanged = ManageEventOf<ButtonsChangedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonsChanged)); this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true); diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs index ab26ab3e..6f423e5d 100644 --- a/src/SMAPI/Framework/Events/ModInputEvents.cs +++ b/src/SMAPI/Framework/Events/ModInputEvents.cs @@ -9,6 +9,13 @@ namespace StardewModdingAPI.Framework.Events /********* ** Accessors *********/ + /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary> + public event EventHandler<ButtonsChangedEventArgs> ButtonsChanged + { + add => this.EventManager.ButtonsChanged.Add(value, this.Mod); + remove => this.EventManager.ButtonsChanged.Remove(value); + } + /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> public event EventHandler<ButtonPressedEventArgs> ButtonPressed { diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index e504218b..ff00cff7 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -208,7 +208,7 @@ namespace StardewModdingAPI.Framework.Logging // show update alert if (File.Exists(Constants.UpdateMarker)) { - string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new [] { '|' }, 2); + string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2); if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion updateFound)) { if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) @@ -286,12 +286,15 @@ namespace StardewModdingAPI.Framework.Logging /// <summary>Log details for settings that don't match the default.</summary> /// <param name="isDeveloperMode">Whether to enable full console output for developers.</param> /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> - public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates) + /// <param name="rewriteMods">Whether to rewrite mods for compatibility.</param> + public void LogSettingsHeader(bool isDeveloperMode, bool checkForUpdates, bool rewriteMods) { if (isDeveloperMode) - this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + this.Monitor.Log("You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI.", LogLevel.Info); if (!checkForUpdates) - this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + this.Monitor.Log("You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI.", LogLevel.Warn); + if (!rewriteMods) + this.Monitor.Log("You configured SMAPI to not rewrite broken mods. Many older mods may fail to load. You can undo this by reinstalling SMAPI.", LogLevel.Warn); if (!this.Monitor.WriteToConsole) this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); this.Monitor.VerboseLog("Verbose logging enabled."); diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs index e1317544..88caf4c3 100644 --- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs @@ -1,5 +1,6 @@ using System; using StardewModdingAPI.Framework.Input; +using StardewModdingAPI.Utilities; namespace StardewModdingAPI.Framework.ModHelpers { @@ -50,6 +51,19 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> + public void SuppressActiveKeybinds(KeybindList keybindList) + { + foreach (Keybind keybind in keybindList.Keybinds) + { + if (!keybind.GetState().IsDown()) + continue; + + foreach (SButton button in keybind.Buttons) + this.Suppress(button); + } + } + + /// <inheritdoc /> public SButtonState GetState(SButton button) { return this.CurrentInputState().GetState(button); diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 9fb5384e..4fae0f44 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The objects to dispose as part of this instance.</summary> private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>(); + /// <summary>Whether to rewrite mods for compatibility.</summary> + private readonly bool RewriteMods; + /********* ** Public methods @@ -45,10 +48,12 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="targetPlatform">The current game platform.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="paranoidMode">Whether to detect paranoid mode issues.</param> - public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode) + /// <param name="rewriteMods">Whether to rewrite mods for compatibility.</param> + public AssemblyLoader(Platform targetPlatform, IMonitor monitor, bool paranoidMode, bool rewriteMods) { this.Monitor = monitor; this.ParanoidMode = paranoidMode; + this.RewriteMods = rewriteMods; this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); // init resolver @@ -308,7 +313,7 @@ namespace StardewModdingAPI.Framework.ModLoading } // find or rewrite code - IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged).ToArray(); + IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray(); RecursiveRewriter rewriter = new RecursiveRewriter( module: module, rewriteType: (type, replaceWith) => diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 3a3f6960..dea08717 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -20,13 +20,15 @@ namespace StardewModdingAPI.Framework.Models [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(VerboseLogging)] = false, - [nameof(LogNetworkTraffic)] = false + [nameof(LogNetworkTraffic)] = false, + [nameof(RewriteMods)] = true }; /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> private static readonly HashSet<string> DefaultSuppressUpdateChecks = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "SMAPI.ConsoleCommands", + "SMAPI.ErrorHandler", "SMAPI.SaveBackup" }; @@ -55,6 +57,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether SMAPI should log more information about the game context.</summary> public bool VerboseLogging { get; set; } + /// <summary>Whether SMAPI should rewrite mods for compatibility.</summary> + public bool RewriteMods { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)]; + /// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary> public bool LogNetworkTraffic { get; set; } @@ -68,7 +73,7 @@ namespace StardewModdingAPI.Framework.Models /******** ** Public methods ********/ - /// <summary>Get the settings which have been customised by the player.</summary> + /// <summary>Get the settings which have been customized by the player.</summary> public IDictionary<string, object> GetCustomSettings() { IDictionary<string, object> custom = new Dictionary<string, object>(); diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs index 5eda71f6..3923700f 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -25,9 +25,15 @@ namespace StardewModdingAPI.Framework.Networking public bool IsHost { get; } /// <inheritdoc /> + public bool IsSplitScreen => this.ScreenID != null; + + /// <inheritdoc /> public bool HasSmapi => this.ApiVersion != null; /// <inheritdoc /> + public int? ScreenID { get; } + + /// <inheritdoc /> public GamePlatform? Platform { get; } /// <inheritdoc /> @@ -45,12 +51,14 @@ namespace StardewModdingAPI.Framework.Networking *********/ /// <summary>Construct an instance.</summary> /// <param name="playerID">The player's unique ID.</param> + /// <param name="screenID">The player's screen ID, if applicable.</param> /// <param name="model">The metadata to copy.</param> /// <param name="sendMessage">A method which sends a message to the peer.</param> /// <param name="isHost">Whether this is a connection to the host player.</param> - public MultiplayerPeer(long playerID, RemoteContextModel model, Action<OutgoingMessage> sendMessage, bool isHost) + public MultiplayerPeer(long playerID, int? screenID, RemoteContextModel model, Action<OutgoingMessage> sendMessage, bool isHost) { this.PlayerID = playerID; + this.ScreenID = screenID; this.IsHost = isHost; if (model != null) { diff --git a/src/SMAPI/Framework/SChatBox.cs b/src/SMAPI/Framework/SChatBox.cs new file mode 100644 index 00000000..e000d1cd --- /dev/null +++ b/src/SMAPI/Framework/SChatBox.cs @@ -0,0 +1,49 @@ +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's implementation of the chatbox which intercepts errors for logging.</summary> + internal class SChatBox : ChatBox + { + /********* + ** Fields + *********/ + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public SChatBox(IMonitor monitor) + { + this.Monitor = monitor; + } + + /// <inheritdoc /> + protected override void runCommand(string command) + { + this.Monitor.Log($"> chat command: {command}"); + base.runCommand(command); + } + + /// <inheritdoc /> + public override void receiveChatMessage(long sourceFarmer, int chatKind, LocalizedContentManager.LanguageCode language, string message) + { + if (chatKind == ChatBox.errorMessage) + { + // log error + this.Monitor.Log(message, LogLevel.Error); + + // add event details if applicable + if (Game1.CurrentEvent != null && message.StartsWith("Event script error:")) + this.Monitor.Log($"In event #{Game1.CurrentEvent.id} for location {Game1.currentLocation?.NameOrUniqueName}", LogLevel.Error); + } + + base.receiveChatMessage(sourceFarmer, chatKind, language, message); + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index f9a36593..cd094ff4 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -124,9 +124,6 @@ namespace StardewModdingAPI.Framework /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second - /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> - private bool IsSaveContentRemoved; - /// <summary>Asset interceptors added or removed since the last tick.</summary> private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>(); @@ -135,7 +132,7 @@ namespace StardewModdingAPI.Framework private readonly ConcurrentQueue<string> RawCommandQueue = new ConcurrentQueue<string>(); /// <summary>A list of commands to execute on each screen.</summary> - private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new(() => new()); + private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new PerScreen<List<Tuple<Command, string, string[]>>>(() => new List<Tuple<Command, string, string[]>>()); /********* @@ -145,6 +142,10 @@ namespace StardewModdingAPI.Framework /// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> internal static DeprecationManager DeprecationManager { get; private set; } + /// <summary>The singleton instance.</summary> + /// <remarks>This is only intended for use by external code like the Error Handler mod.</remarks> + internal static SCore Instance { get; private set; } + /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> internal static uint TicksElapsed { get; private set; } @@ -157,6 +158,8 @@ namespace StardewModdingAPI.Framework /// <param name="writeToConsole">Whether to output log messages to the console.</param> public SCore(string modsPath, bool writeToConsole) { + SCore.Instance = this; + // init paths this.VerifyPath(modsPath); this.VerifyPath(Constants.LogDir); @@ -205,6 +208,7 @@ namespace StardewModdingAPI.Framework { JsonConverter[] converters = { new ColorConverter(), + new KeybindConverter(), new PointConverter(), new Vector2Converter(), new RectangleConverter() @@ -245,12 +249,7 @@ namespace StardewModdingAPI.Framework // apply game patches new GamePatcher(this.Monitor).Apply( - new EventErrorPatch(this.LogManager.MonitorForGame), - new DialogueErrorPatch(this.LogManager.MonitorForGame, this.Reflection), - new ObjectErrorPatch(), - new LoadContextPatch(this.Reflection, this.OnLoadStageChanged), - new LoadErrorPatch(this.Monitor, this.OnSaveContentRemoved), - new ScheduleErrorPatch(this.LogManager.MonitorForGame) + new LoadContextPatch(this.Reflection, this.OnLoadStageChanged) ); // add exit handler @@ -278,7 +277,7 @@ namespace StardewModdingAPI.Framework // log basic info this.LogManager.HandleMarkerFiles(); - this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates); + this.LogManager.LogSettingsHeader(this.Settings.DeveloperMode, this.Settings.CheckForUpdates, this.Settings.RewriteMods); // set window titles this.SetWindowTitles( @@ -517,15 +516,6 @@ namespace StardewModdingAPI.Framework this.ScreenCommandQueue.GetValueForScreen(screenId).Add(Tuple.Create(command, name, args)); } - /********* - ** Show in-game warnings (for main player only) - *********/ - // save content removed - if (this.IsSaveContentRemoved && Context.IsWorldReady) - { - this.IsSaveContentRemoved = false; - Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type)); - } /********* ** Run game update @@ -827,24 +817,29 @@ namespace StardewModdingAPI.Framework } // raise input button events - foreach (var pair in inputState.ButtonStates) + if (inputState.ButtonStates.Count > 0) { - SButton button = pair.Key; - SButtonState status = pair.Value; + events.ButtonsChanged.Raise(new ButtonsChangedEventArgs(cursor, inputState)); - if (status == SButtonState.Pressed) + foreach (var pair in inputState.ButtonStates) { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} pressed."); + SButton button = pair.Key; + SButtonState status = pair.Value; - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - } - else if (status == SButtonState.Released) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} released."); + if (status == SButtonState.Pressed) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} pressed."); - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + } + else if (status == SButtonState.Released) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} released."); + + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + } } } } @@ -1065,6 +1060,13 @@ namespace StardewModdingAPI.Framework this.OnReturnedToTitle(); } + // override chatbox + if (newStage == LoadStage.Loaded) + { + Game1.onScreenMenus.Remove(Game1.chatBox); + Game1.onScreenMenus.Add(Game1.chatBox = new SChatBox(this.LogManager.MonitorForGame)); + } + // raise events this.EventManager.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); if (newStage == LoadStage.None) @@ -1105,12 +1107,6 @@ namespace StardewModdingAPI.Framework Game1.CustomData[migrationKey] = Constants.ApiVersion.ToString(); } - /// <summary>Raised after custom content is removed from the save data to avoid a crash.</summary> - internal void OnSaveContentRemoved() - { - this.IsSaveContentRemoved = true; - } - /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> protected void OnNewDayAfterFade() { @@ -1406,7 +1402,7 @@ namespace StardewModdingAPI.Framework // load mods IList<IModMetadata> skippedMods = new List<IModMetadata>(); - using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings)) + using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods)) { // init HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase); diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 42a712ee..af7fa387 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -11,6 +11,7 @@ using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Locations; @@ -81,6 +82,9 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary> public bool IsBetweenCreateEvents { get; set; } + /// <summary>The cached <see cref="Farmer.UniqueMultiplayerID"/> value for this instance's player.</summary> + public long? PlayerId { get; private set; } + /// <summary>Construct a content manager to read game content files.</summary> /// <remarks>This must be static because the game accesses it before the <see cref="SGame"/> constructor is called.</remarks> [NonInstancedStatic] @@ -121,6 +125,18 @@ namespace StardewModdingAPI.Framework this.OnUpdating = onUpdating; } + /// <summary>Get the current input state for a button.</summary> + /// <param name="button">The button to check.</param> + /// <remarks>This is intended for use by <see cref="Keybind"/> and shouldn't be used directly in most cases.</remarks> + internal static SButtonState GetInputState(SButton button) + { + SInputState input = Game1.input as SInputState; + if (input == null) + throw new InvalidOperationException("SMAPI's input state is not in a ready state yet."); + + return input.GetState(button); + } + /********* ** Protected methods @@ -167,6 +183,7 @@ namespace StardewModdingAPI.Framework try { this.OnUpdating(this, gameTime, () => base.Update(gameTime)); + this.PlayerId = Game1.player?.UniqueMultiplayerID; } finally { diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs index ae06f513..45e7369c 100644 --- a/src/SMAPI/Framework/SGameRunner.cs +++ b/src/SMAPI/Framework/SGameRunner.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Events; @@ -49,6 +50,13 @@ namespace StardewModdingAPI.Framework /********* ** Public methods *********/ + /// <summary>The singleton instance.</summary> + public static SGameRunner Instance => (SGameRunner)GameRunner.instance; + + + /********* + ** Public methods + *********/ /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> /// <param name="reflection">Simplifies access to private game code.</param> @@ -99,15 +107,24 @@ namespace StardewModdingAPI.Framework } /// <inheritdoc /> - public override void RemoveGameInstance(Game1 instance) + public override void RemoveGameInstance(Game1 gameInstance) { - base.RemoveGameInstance(instance); + base.RemoveGameInstance(gameInstance); if (this.gameInstances.Count <= 1) EarlyConstants.LogScreenId = null; this.UpdateForSplitScreenChanges(); } + /// <summary>Get the screen ID for a given player ID, if the player is local.</summary> + /// <param name="playerId">The player ID to check.</param> + public int? GetScreenId(long playerId) + { + return this.gameInstances + .FirstOrDefault(p => ((SGame)p).PlayerId == playerId) + ?.instanceId; + } + /********* ** Protected methods @@ -136,6 +153,7 @@ namespace StardewModdingAPI.Framework this.OnGameUpdating(gameTime, () => base.Update(gameTime)); } + /// <summary>Update metadata when a split screen is added or removed.</summary> private void UpdateForSplitScreenChanges() { HashSet<int> oldScreenIds = new HashSet<int>(Context.ActiveScreenIds); diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 2f89fce9..5956b63f 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -56,10 +56,10 @@ namespace StardewModdingAPI.Framework private readonly bool LogNetworkTraffic; /// <summary>The backing field for <see cref="Peers"/>.</summary> - private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new(() => new Dictionary<long, MultiplayerPeer>()); + private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new PerScreen<IDictionary<long, MultiplayerPeer>>(() => new Dictionary<long, MultiplayerPeer>()); /// <summary>The backing field for <see cref="HostPeer"/>.</summary> - private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new(); + private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new PerScreen<MultiplayerPeer>(); /********* @@ -196,7 +196,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); // store peer - MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); + MultiplayerPeer newPeer = new MultiplayerPeer( + playerID: message.FarmerID, + screenID: this.GetScreenId(message.FarmerID), + model: model, + sendMessage: sendMessage, + isHost: false + ); if (this.Peers.ContainsKey(message.FarmerID)) { this.Monitor.Log($"Received mod context from farmhand {message.FarmerID}, but the game didn't see them disconnect. This may indicate issues with the network connection.", LogLevel.Info); @@ -238,7 +244,13 @@ namespace StardewModdingAPI.Framework if (!this.Peers.ContainsKey(message.FarmerID)) { this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); - MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: false); + MultiplayerPeer peer = new MultiplayerPeer( + playerID: message.FarmerID, + screenID: this.GetScreenId(message.FarmerID), + model: null, + sendMessage: sendMessage, + isHost: false + ); this.AddPeer(peer, canBeHost: false); } @@ -280,7 +292,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); // store peer - MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: model?.IsHost ?? this.HostPeer == null); + MultiplayerPeer peer = new MultiplayerPeer( + playerID: message.FarmerID, + screenID: this.GetScreenId(message.FarmerID), + model: model, + sendMessage: sendMessage, + isHost: model?.IsHost ?? this.HostPeer == null + ); if (peer.IsHost && this.HostPeer != null) { this.Monitor.Log($"Rejected mod context from host player {peer.PlayerID}: already received host data from {(peer.PlayerID == this.HostPeer.PlayerID ? "that player" : $"player {peer.PlayerID}")}.", LogLevel.Error); @@ -297,7 +315,14 @@ namespace StardewModdingAPI.Framework if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null) { this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace); - this.AddPeer(new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: true), canBeHost: false); + var peer = new MultiplayerPeer( + playerID: message.FarmerID, + screenID: this.GetScreenId(message.FarmerID), + model: null, + sendMessage: sendMessage, + isHost: true + ); + this.AddPeer(peer, canBeHost: false); } resume(); break; @@ -309,7 +334,13 @@ namespace StardewModdingAPI.Framework // store peer if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer)) { - peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: this.HostPeer == null); + peer = new MultiplayerPeer( + playerID: message.FarmerID, + screenID: this.GetScreenId(message.FarmerID), + model: null, + sendMessage: sendMessage, + isHost: this.HostPeer == null + ); this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace); this.AddPeer(peer, canBeHost: true); } @@ -361,34 +392,24 @@ namespace StardewModdingAPI.Framework if (string.IsNullOrWhiteSpace(fromModID)) throw new ArgumentNullException(nameof(fromModID)); - // get target players - long curPlayerId = Game1.player.UniqueMultiplayerID; - bool sendToSelf = false; - List<MultiplayerPeer> sendToPeers = new List<MultiplayerPeer>(); - if (toPlayerIDs == null) - { - sendToSelf = true; - sendToPeers.AddRange(this.Peers.Values); - } - else + // get valid peers + var sendToPeers = this.Peers.Values.Where(p => p.HasSmapi).ToList(); + bool sendToSelf = true; + + // filter by player ID + if (toPlayerIDs != null) { - foreach (long id in toPlayerIDs.Distinct()) - { - if (id == curPlayerId) - sendToSelf = true; - else if (this.Peers.TryGetValue(id, out MultiplayerPeer peer) && peer.HasSmapi) - sendToPeers.Add(peer); - } + var ids = new HashSet<long>(toPlayerIDs); + sendToPeers.RemoveAll(peer => !ids.Contains(peer.PlayerID)); + sendToSelf = ids.Contains(Game1.player.UniqueMultiplayerID); } // filter by mod ID if (toModIDs != null) { - HashSet<string> sendToMods = new HashSet<string>(toModIDs, StringComparer.OrdinalIgnoreCase); - if (sendToSelf && toModIDs.All(id => this.ModRegistry.Get(id) == null)) - sendToSelf = false; - - sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !sendToMods.Contains(mod.ID))); + var ids = new HashSet<string>(toModIDs, StringComparer.OrdinalIgnoreCase); + sendToPeers.RemoveAll(peer => peer.Mods.All(mod => !ids.Contains(mod.ID))); + sendToSelf = sendToSelf && toModIDs.Any(id => this.ModRegistry.Get(id) != null); } // validate recipients @@ -505,6 +526,13 @@ namespace StardewModdingAPI.Framework } } + /// <summary>Get the screen ID for a given player ID, if the player is local.</summary> + /// <param name="playerId">The player ID to check.</param> + private int? GetScreenId(long playerId) + { + return SGameRunner.Instance.GetScreenId(playerId); + } + /// <summary>Get all connected player IDs, including the current player.</summary> private IEnumerable<long> GetKnownPlayerIDs() { diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs new file mode 100644 index 00000000..93a274a8 --- /dev/null +++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs @@ -0,0 +1,82 @@ +using System; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Framework.Serialization +{ + /// <summary>Handles deserialization of <see cref="Keybind"/> and <see cref="KeybindList"/> models.</summary> + internal class KeybindConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public override bool CanRead { get; } = true; + + /// <inheritdoc /> + public override bool CanWrite { get; } = true; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return + typeof(Keybind).IsAssignableFrom(objectType) + || typeof(KeybindList).IsAssignableFrom(objectType); + } + + /// <summary>Reads the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + string path = reader.Path; + + switch (reader.TokenType) + { + case JsonToken.Null: + return objectType == typeof(Keybind) + ? (object)new Keybind() + : new KeybindList(); + + case JsonToken.String: + { + string str = JToken.Load(reader).Value<string>(); + + if (objectType == typeof(Keybind)) + { + return Keybind.TryParse(str, out Keybind parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); + } + else + { + return KeybindList.TryParse(str, out KeybindList parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); + } + } + + default: + throw new SParseException($"Can't parse {objectType} from unexpected {reader.TokenType} node (path: {reader.Path})."); + } + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + writer.WriteValue(value?.ToString()); + } + } +} diff --git a/src/SMAPI/IInputHelper.cs b/src/SMAPI/IInputHelper.cs index e9768c24..2b907b0d 100644 --- a/src/SMAPI/IInputHelper.cs +++ b/src/SMAPI/IInputHelper.cs @@ -1,3 +1,5 @@ +using StardewModdingAPI.Utilities; + namespace StardewModdingAPI { /// <summary>Provides an API for checking and changing input state.</summary> @@ -18,6 +20,10 @@ namespace StardewModdingAPI /// <param name="button">The button to suppress.</param> void Suppress(SButton button); + /// <summary>Suppress the keybinds which are currently down.</summary> + /// <param name="keybindList">The keybind list whose active keybinds to suppress.</param> + void SuppressActiveKeybinds(KeybindList keybindList); + /// <summary>Get the state of a button.</summary> /// <param name="button">The button to check.</param> SButtonState GetState(SButton button); diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs index 0d4d3261..47084174 100644 --- a/src/SMAPI/IMultiplayerPeer.cs +++ b/src/SMAPI/IMultiplayerPeer.cs @@ -14,9 +14,16 @@ namespace StardewModdingAPI /// <summary>Whether this is a connection to the host player.</summary> bool IsHost { get; } + /// <summary>Whether this a local player on the same computer in split-screen mote.</summary> + bool IsSplitScreen { get; } + /// <summary>Whether the player has SMAPI installed.</summary> bool HasSmapi { get; } + /// <summary>The player's screen ID, if applicable.</summary> + /// <remarks>See <see cref="Context.ScreenId"/> for details. This is only visible to players in split-screen mode. A remote player won't see this value, even if the other players are in split-screen mode.</remarks> + int? ScreenID { get; } + /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> GamePlatform? Platform { get; } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index bd1cc50e..4b911a83 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -374,52 +374,6 @@ namespace StardewModdingAPI.Metadata return this.ReloadSuspensionBridges(content, key); /**** - ** Content\TileSheets - ****/ - case "tilesheets\\chairtiles": // Game1.LoadContent - MapSeat.mapChairTexture = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\critters": // Critter constructor - return this.ReloadCritterTextures(content, key) > 0; - - case "tilesheets\\crops": // Game1.LoadContent - Game1.cropSpriteSheet = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\debris": // Game1.LoadContent - Game1.debrisSpriteSheet = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\emotes": // Game1.LoadContent - Game1.emoteSpriteSheet = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\furniture": // Game1.LoadContent - Furniture.furnitureTexture = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\furniturefront": // Game1.LoadContent - Furniture.furnitureFrontTexture = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\projectiles": // Game1.LoadContent - Projectile.projectileSheet = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\rain": // Game1.LoadContent - Game1.rainTexture = content.Load<Texture2D>(key); - return true; - - case "tilesheets\\tools": // Game1.ResetToolSpriteSheet - Game1.ResetToolSpriteSheet(); - return true; - - case "tilesheets\\weapons": // Game1.LoadContent - Tool.weaponsTexture = content.Load<Texture2D>(key); - return true; - - /**** ** Content\Maps ****/ case "maps\\menutiles": // Game1.LoadContent @@ -455,6 +409,12 @@ namespace StardewModdingAPI.Metadata return this.ReloadTitleButtons(content, key); /**** + ** Content\Strings + ****/ + case "strings\\stringsfromcsfiles": + return this.ReloadStringsFromCsFiles(content); + + /**** ** Content\TileSheets ****/ case "tilesheets\\animations": // Game1.LoadContent @@ -469,14 +429,57 @@ namespace StardewModdingAPI.Metadata Bush.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); return true; + case "tilesheets\\chairtiles": // Game1.LoadContent + MapSeat.mapChairTexture = content.Load<Texture2D>(key); + return true; + case "tilesheets\\craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); return true; + case "tilesheets\\critters": // Critter constructor + return this.ReloadCritterTextures(content, key) > 0; + + case "tilesheets\\crops": // Game1.LoadContent + Game1.cropSpriteSheet = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\debris": // Game1.LoadContent + Game1.debrisSpriteSheet = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\emotes": // Game1.LoadContent + Game1.emoteSpriteSheet = content.Load<Texture2D>(key); + return true; + case "tilesheets\\fruittrees": // FruitTree FruitTree.texture = content.Load<Texture2D>(key); return true; + case "tilesheets\\furniture": // Game1.LoadContent + Furniture.furnitureTexture = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\furniturefront": // Game1.LoadContent + Furniture.furnitureFrontTexture = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\projectiles": // Game1.LoadContent + Projectile.projectileSheet = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\rain": // Game1.LoadContent + Game1.rainTexture = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\tools": // Game1.ResetToolSpriteSheet + Game1.ResetToolSpriteSheet(); + return true; + + case "tilesheets\\weapons": // Game1.LoadContent + Tool.weaponsTexture = content.Load<Texture2D>(key); + return true; + /**** ** Content\TerrainFeatures ****/ @@ -528,6 +531,9 @@ namespace StardewModdingAPI.Metadata return this.ReloadTreeTextures(content, key, Tree.pineTree); } + /**** + ** Dynamic assets + ****/ // dynamic textures if (this.KeyStartsWith(key, "animals\\cat")) return this.ReloadPetOrHorseSprites<Cat>(content, key); @@ -778,26 +784,10 @@ namespace StardewModdingAPI.Metadata /// <param name="location">The location whose map to reload.</param> private void ReloadMap(GameLocation location) { - // reset patch caches - switch (location) - { - case Town _: - this.Reflection.GetField<bool>(location, "ccRefurbished").SetValue(false); - this.Reflection.GetField<bool>(location, "isShowingDestroyedJoja").SetValue(false); - this.Reflection.GetField<bool>(location, "isShowingUpgradedPamHouse").SetValue(false); - break; - - case Beach _: - case BeachNightMarket _: - case Forest _: - this.Reflection.GetField<bool>(location, "hasShownCCUpgrade").SetValue(false); - break; - } - - // general updates + // reload map location.reloadMap(); - location.updateSeasonalTileSheets(); location.updateWarps(); + location.MakeMapModifications(force: true); // update interior doors location.interiorDoors.Clear(); @@ -1028,6 +1018,27 @@ namespace StardewModdingAPI.Metadata return true; } + /// <summary>Reload cached translations from the <c>Strings\StringsFromCSFiles</c> asset.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <returns>Returns whether any data was reloaded.</returns> + /// <remarks>Derived from the <see cref="Game1.TranslateFields"/>.</remarks> + private bool ReloadStringsFromCsFiles(LocalizedContentManager content) + { + Game1.samBandName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2156"); + Game1.elliottBookName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2157"); + + string[] dayNames = this.Reflection.GetField<string[]>(typeof(Game1), "_shortDayDisplayName").GetValue(); + dayNames[0] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3042"); + dayNames[1] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3043"); + dayNames[2] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3044"); + dayNames[3] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3045"); + dayNames[4] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3046"); + dayNames[5] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3047"); + dayNames[6] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3048"); + + return true; + } + /**** ** Helpers ****/ diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 2c1e14ce..d1699636 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -27,28 +27,32 @@ namespace StardewModdingAPI.Metadata /// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary> /// <param name="paranoidMode">Whether to detect paranoid mode issues.</param> /// <param name="platformChanged">Whether the assembly was rewritten for crossplatform compatibility.</param> - public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode, bool platformChanged) + /// <param name="rewriteMods">Whether to get handlers which rewrite mods for compatibility.</param> + public IEnumerable<IInstructionHandler> GetHandlers(bool paranoidMode, bool platformChanged, bool rewriteMods) { /**** ** rewrite CIL to fix incompatible code ****/ // rewrite for crossplatform compatibility - if (platformChanged) - yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade)); + if (rewriteMods) + { + if (platformChanged) + yield return new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchFacade)); - // rewrite for Stardew Valley 1.5 - yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture)); - yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); - yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); + // rewrite for Stardew Valley 1.5 + yield return new FieldReplaceRewriter(typeof(DecoratableLocation), "furniture", typeof(GameLocation), nameof(GameLocation.furniture)); + yield return new FieldReplaceRewriter(typeof(Farm), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); + yield return new FieldReplaceRewriter(typeof(MineShaft), "resourceClumps", typeof(GameLocation), nameof(GameLocation.resourceClumps)); - // heuristic rewrites - yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); - yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); + // heuristic rewrites + yield return new HeuristicFieldRewriter(this.ValidateReferencesToAssemblies); + yield return new HeuristicMethodRewriter(this.ValidateReferencesToAssemblies); #if HARMONY_2 - // rewrite for SMAPI 3.6 (Harmony 1.x => 2.0 update) - yield return new Harmony1AssemblyRewriter(); + // rewrite for SMAPI 3.x (Harmony 1.x => 2.0 update) + yield return new Harmony1AssemblyRewriter(); #endif + } /**** ** detect mod issues diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs index ee8a1674..ae758e9b 100644 --- a/src/SMAPI/Properties/AssemblyInfo.cs +++ b/src/SMAPI/Properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("SMAPI.Tests")] +[assembly: InternalsVisibleTo("ErrorHandler")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/SMAPI/SButtonState.cs b/src/SMAPI/SButtonState.cs index 2b78da27..5f3e8d3c 100644 --- a/src/SMAPI/SButtonState.cs +++ b/src/SMAPI/SButtonState.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI } /// <summary>Extension methods for <see cref="SButtonState"/>.</summary> - internal static class InputStatusExtensions + public static class InputStatusExtensions { /// <summary>Whether the button was pressed or held.</summary> /// <param name="state">The button state.</param> diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 6ba64fe7..7a710f14 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -34,6 +34,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future "DeveloperMode": true, /** + * Whether SMAPI should rewrite mods for compatibility. This may prevent older mods from + * loading, but bypasses a Visual Studio crash when debugging. + */ + "RewriteMods": true, + + /** * Whether to add a section to the 'mod issues' list for mods which directly use potentially * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as * part of their normal functionality, so these warnings are meaningless without further @@ -113,6 +119,7 @@ copy all the settings, or you may cause bugs due to overridden changes in future */ "SuppressUpdateChecks": [ "SMAPI.ConsoleCommands", + "SMAPI.ErrorHandler", "SMAPI.SaveBackup" ] } diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs new file mode 100644 index 00000000..dd8d2861 --- /dev/null +++ b/src/SMAPI/Utilities/Keybind.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A single multi-key binding which can be triggered by the player.</summary> + /// <remarks>NOTE: this is part of <see cref="KeybindList"/>, and usually shouldn't be used directly.</remarks> + public class Keybind + { + /********* + ** Fields + *********/ + /// <summary>Get the current input state for a button.</summary> + [Obsolete("This property should only be used for unit tests.")] + internal Func<SButton, SButtonState> GetButtonState { get; set; } = SGame.GetInputState; + + + /********* + ** Accessors + *********/ + /// <summary>The buttons that must be down to activate the keybind.</summary> + public SButton[] Buttons { get; } + + /// <summary>Whether any keys are bound.</summary> + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="buttons">The buttons that must be down to activate the keybind.</param> + public Keybind(params SButton[] buttons) + { + this.Buttons = buttons; + this.IsBound = buttons.Any(p => p != SButton.None); + } + + /// <summary>Parse a keybind string, if it's valid.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <param name="parsed">The parsed keybind, if valid.</param> + /// <param name="errors">The parse errors, if any.</param> + public static bool TryParse(string input, out Keybind parsed, out string[] errors) + { + // empty input + if (string.IsNullOrWhiteSpace(input)) + { + parsed = new Keybind(SButton.None); + errors = new string[0]; + return true; + } + + // parse buttons + string[] rawButtons = input.Split('+'); + SButton[] buttons = new SButton[rawButtons.Length]; + List<string> rawErrors = new List<string>(); + for (int i = 0; i < buttons.Length; i++) + { + string rawButton = rawButtons[i].Trim(); + if (string.IsNullOrWhiteSpace(rawButton)) + rawErrors.Add("Invalid empty button value"); + else if (!Enum.TryParse(rawButton, ignoreCase: true, out SButton button)) + { + string error = $"Invalid button value '{rawButton}'"; + + switch (rawButton.ToLower()) + { + case "shift": + error += $" (did you mean {SButton.LeftShift}?)"; + break; + + case "ctrl": + case "control": + error += $" (did you mean {SButton.LeftControl}?)"; + break; + + case "alt": + error += $" (did you mean {SButton.LeftAlt}?)"; + break; + } + + rawErrors.Add(error); + } + else + buttons[i] = button; + } + + // build result + if (rawErrors.Any()) + { + parsed = null; + errors = rawErrors.ToArray(); + return false; + } + else + { + parsed = new Keybind(buttons); + errors = new string[0]; + return true; + } + } + + /// <summary>Get the keybind state relative to the previous tick.</summary> + public SButtonState GetState() + { + SButtonState[] states = this.Buttons.Select(this.GetButtonState).Distinct().ToArray(); + + // single state + if (states.Length == 1) + return states[0]; + + // if any key has no state, the whole set wasn't enabled last tick + if (states.Contains(SButtonState.None)) + return SButtonState.None; + + // mix of held + pressed => pressed + if (states.All(p => p == SButtonState.Pressed || p == SButtonState.Held)) + return SButtonState.Pressed; + + // mix of held + released => released + if (states.All(p => p == SButtonState.Held || p == SButtonState.Released)) + return SButtonState.Released; + + // not down last tick or now + return SButtonState.None; + } + + /// <summary>Get a string representation of the keybind.</summary> + /// <remarks>A keybind is serialized to a string like <c>LeftControl + S</c>, where each key is separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks> + public override string ToString() + { + return this.Buttons.Length > 0 + ? string.Join(" + ", this.Buttons) + : SButton.None.ToString(); + } + } +} diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs new file mode 100644 index 00000000..1845285a --- /dev/null +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -0,0 +1,161 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Serialization; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A set of multi-key bindings which can be triggered by the player.</summary> + public class KeybindList + { + /********* + ** Accessors + *********/ + /// <summary>The individual keybinds.</summary> + public Keybind[] Keybinds { get; } + + /// <summary>Whether any keys are bound.</summary> + public bool IsBound { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="keybinds">The underlying keybinds.</param> + /// <remarks>See <see cref="Parse"/> or <see cref="TryParse"/> to parse it from a string representation. You can also use this type directly in your config or JSON data models, and it'll be parsed by SMAPI.</remarks> + public KeybindList(params Keybind[] keybinds) + { + this.Keybinds = keybinds.Where(p => p.IsBound).ToArray(); + this.IsBound = this.Keybinds.Any(); + } + + /// <summary>Parse a keybind list from a string, and throw an exception if it's not valid.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <exception cref="FormatException">The <paramref name="input"/> format is invalid.</exception> + public static KeybindList Parse(string input) + { + return KeybindList.TryParse(input, out KeybindList parsed, out string[] errors) + ? parsed + : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{input}'.\n{string.Join("\n", errors)}"); + } + + /// <summary>Try to parse a keybind list from a string.</summary> + /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> + /// <param name="parsed">The parsed keybind list, if valid.</param> + /// <param name="errors">The errors that occurred while parsing the input, if any.</param> + public static bool TryParse(string input, out KeybindList parsed, out string[] errors) + { + // empty input + if (string.IsNullOrWhiteSpace(input)) + { + parsed = new KeybindList(); + errors = new string[0]; + return true; + } + + // parse buttons + var rawErrors = new List<string>(); + var keybinds = new List<Keybind>(); + foreach (string rawSet in input.Split(',')) + { + if (string.IsNullOrWhiteSpace(rawSet)) + continue; + + if (!Keybind.TryParse(rawSet, out Keybind keybind, out string[] curErrors)) + rawErrors.AddRange(curErrors); + else + keybinds.Add(keybind); + } + + // build result + if (rawErrors.Any()) + { + parsed = null; + errors = rawErrors.Distinct().ToArray(); + return false; + } + else + { + parsed = new KeybindList(keybinds.ToArray()); + errors = new string[0]; + return true; + } + } + + /// <summary>Get a keybind list for a single keybind.</summary> + /// <param name="buttons">The buttons that must be down to activate the keybind.</param> + public static KeybindList ForSingle(params SButton[] buttons) + { + return new KeybindList( + new Keybind(buttons) + ); + } + + /// <summary>Get the overall keybind list state relative to the previous tick.</summary> + /// <remarks>States are transitive across keybind. For example, if one keybind is 'released' and another is 'pressed', the state of the keybind list is 'held'.</remarks> + public SButtonState GetState() + { + bool wasPressed = false; + bool isPressed = false; + + foreach (Keybind keybind in this.Keybinds) + { + switch (keybind.GetState()) + { + case SButtonState.Pressed: + isPressed = true; + break; + + case SButtonState.Held: + wasPressed = true; + isPressed = true; + break; + + case SButtonState.Released: + wasPressed = true; + break; + } + } + + if (wasPressed == isPressed) + { + return wasPressed + ? SButtonState.Held + : SButtonState.None; + } + + return wasPressed + ? SButtonState.Released + : SButtonState.Pressed; + } + + /// <summary>Get whether any of the button sets are pressed.</summary> + public bool IsDown() + { + SButtonState state = this.GetState(); + return state == SButtonState.Pressed || state == SButtonState.Held; + } + + /// <summary>Get whether the input binding was just pressed this tick.</summary> + public bool JustPressed() + { + return this.GetState() == SButtonState.Pressed; + } + + /// <summary>Get the keybind which is currently down, if any. If there are multiple keybinds down, the first one is returned.</summary> + public Keybind GetKeybindCurrentlyDown() + { + return this.Keybinds.FirstOrDefault(p => p.GetState().IsDown()); + } + + /// <summary>Get a string representation of the input binding.</summary> + /// <remarks>A keybind list is serialized to a string like <c>LeftControl + S, LeftAlt + S</c>, where each multi-key binding is separated with <c>,</c> and the keys within each keybind are separated with <c>+</c>. The key order is commutative, so <c>LeftControl + S</c> and <c>S + LeftControl</c> are identical.</remarks> + public override string ToString() + { + return this.Keybinds.Length > 0 + ? string.Join(", ", this.Keybinds.Select(p => p.ToString())) + : SButton.None.ToString(); + } + } +} diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 89d08e87..20b8fbce 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -11,10 +11,10 @@ namespace StardewModdingAPI.Utilities /********* ** Fields *********/ - /// <summary>Create the initial value for a player.</summary> + /// <summary>Create the initial value for a screen.</summary> private readonly Func<T> CreateNewState; - /// <summary>The tracked values for each player.</summary> + /// <summary>The tracked values for each screen.</summary> private readonly IDictionary<int, T> States = new Dictionary<int, T>(); /// <summary>The last <see cref="Context.LastRemovedScreenId"/> value for which this instance was updated.</summary> @@ -24,8 +24,8 @@ namespace StardewModdingAPI.Utilities /********* ** Accessors *********/ - /// <summary>The value for the current player.</summary> - /// <remarks>The value is initialized the first time it's requested for that player, unless it's set manually first.</remarks> + /// <summary>The value for the current screen.</summary> + /// <remarks>The value is initialized the first time it's requested for that screen, unless it's set manually first.</remarks> public T Value { get => this.GetValueForScreen(Context.ScreenId); @@ -41,47 +41,66 @@ namespace StardewModdingAPI.Utilities : this(null) { } /// <summary>Construct an instance.</summary> - /// <param name="createNewState">Create the initial state for a player screen.</param> + /// <param name="createNewState">Create the initial state for a screen.</param> public PerScreen(Func<T> createNewState) { this.CreateNewState = createNewState ?? (() => default); } + /// <summary>Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet.</summary> + public IEnumerable<KeyValuePair<int, T>> GetActiveValues() + { + this.RemoveDeadScreens(); + return this.States.ToArray(); + } + /// <summary>Get the value for a given screen ID, creating it if needed.</summary> /// <param name="screenId">The screen ID to check.</param> - internal T GetValueForScreen(int screenId) + public T GetValueForScreen(int screenId) { - this.RemoveDeadPlayers(); + this.RemoveDeadScreens(); return this.States.TryGetValue(screenId, out T state) ? state : this.States[screenId] = this.CreateNewState(); } - /// <summary>Set the value for a given screen ID, creating it if needed.</summary> + /// <summary>Set the value for a given screen ID.</summary> /// <param name="screenId">The screen ID whose value set.</param> /// <param name="value">The value to set.</param> - internal void SetValueForScreen(int screenId, T value) + public void SetValueForScreen(int screenId, T value) { - this.RemoveDeadPlayers(); + this.RemoveDeadScreens(); this.States[screenId] = value; } + /// <summary>Remove all active values.</summary> + public void ResetAllScreens() + { + this.RemoveScreens(p => true); + } + /********* ** Private methods *********/ - /// <summary>Remove players who are no longer have a split-screen index.</summary> - /// <returns>Returns whether any players were removed.</returns> - private void RemoveDeadPlayers() + /// <summary>Remove screens which are no longer active.</summary> + private void RemoveDeadScreens() { if (this.LastRemovedScreenId == Context.LastRemovedScreenId) return; - this.LastRemovedScreenId = Context.LastRemovedScreenId; - foreach (int id in this.States.Keys.ToArray()) + + this.RemoveScreens(id => !Context.HasScreenId(id)); + } + + /// <summary>Remove screens matching a condition.</summary> + /// <param name="shouldRemove">Returns whether a screen ID should be removed.</param> + private void RemoveScreens(Func<int, bool> shouldRemove) + { + foreach (var pair in this.States.ToArray()) { - if (!Context.HasScreenId(id)) - this.States.Remove(id); + if (shouldRemove(pair.Key)) + this.States.Remove(pair.Key); } } } diff --git a/src/SMAPI/i18n/de.json b/src/SMAPI/i18n/de.json index a8cbd83b..595c3eff 100644 --- a/src/SMAPI/i18n/de.json +++ b/src/SMAPI/i18n/de.json @@ -1,10 +1,6 @@ { - // error messages - "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}", "generic.date-with-year": "{{season}} {{day}} im Jahr {{year}}" - } diff --git a/src/SMAPI/i18n/default.json b/src/SMAPI/i18n/default.json index 7a3d3ed5..7e1f9c4d 100644 --- a/src/SMAPI/i18n/default.json +++ b/src/SMAPI/i18n/default.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}", diff --git a/src/SMAPI/i18n/es.json b/src/SMAPI/i18n/es.json index c9843991..76228d7d 100644 --- a/src/SMAPI/i18n/es.json +++ b/src/SMAPI/i18n/es.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{seasonLowercase}} {{day}}", diff --git a/src/SMAPI/i18n/fr.json b/src/SMAPI/i18n/fr.json index 5969aa20..e32ee712 100644 --- a/src/SMAPI/i18n/fr.json +++ b/src/SMAPI/i18n/fr.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{day}} {{seasonLowercase}}", diff --git a/src/SMAPI/i18n/hu.json b/src/SMAPI/i18n/hu.json index 785012f4..2e3b7264 100644 --- a/src/SMAPI/i18n/hu.json +++ b/src/SMAPI/i18n/hu.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Érvénytelen elemek kerültek eltávolításra, hogy a játék ne omoljon össze (további információk a SMAPI konzolon).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}", diff --git a/src/SMAPI/i18n/it.json b/src/SMAPI/i18n/it.json index 3b3351c3..7ada11f0 100644 --- a/src/SMAPI/i18n/it.json +++ b/src/SMAPI/i18n/it.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{day}} {{season}}", diff --git a/src/SMAPI/i18n/ja.json b/src/SMAPI/i18n/ja.json index 1f814bfa..c95ac1b1 100644 --- a/src/SMAPI/i18n/ja.json +++ b/src/SMAPI/i18n/ja.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}日", diff --git a/src/SMAPI/i18n/ko.json b/src/SMAPI/i18n/ko.json index d5bbffa4..8d267e5e 100644 --- a/src/SMAPI/i18n/ko.json +++ b/src/SMAPI/i18n/ko.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "충돌을 방지하기 위해 잘못된 컨텐츠가 제거되었습니다 (자세한 내용은 SMAPI 콘솔 참조).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}", diff --git a/src/SMAPI/i18n/pt.json b/src/SMAPI/i18n/pt.json index e8460922..7a08b08f 100644 --- a/src/SMAPI/i18n/pt.json +++ b/src/SMAPI/i18n/pt.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}} {{day}}", diff --git a/src/SMAPI/i18n/ru.json b/src/SMAPI/i18n/ru.json index 002fdbf8..b8ff55c4 100644 --- a/src/SMAPI/i18n/ru.json +++ b/src/SMAPI/i18n/ru.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}}, {{day}}-е число", diff --git a/src/SMAPI/i18n/tr.json b/src/SMAPI/i18n/tr.json index 2a6e83a1..e97a48ba 100644 --- a/src/SMAPI/i18n/tr.json +++ b/src/SMAPI/i18n/tr.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut).", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{day}} {{season}}", diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json index cdbe3b74..36d459de 100644 --- a/src/SMAPI/i18n/zh.json +++ b/src/SMAPI/i18n/zh.json @@ -1,7 +1,4 @@ { - // error messages - "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)", - // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) "generic.date": "{{season}}{{day}}日", |