diff --git a/build/common.targets b/build/common.targets index c09d58a3..4880eab6 100644 --- a/build/common.targets +++ b/build/common.targets @@ -4,7 +4,7 @@ - 3.4.1 + 3.5.0 SMAPI $(AssemblySearchPaths);{GAC} diff --git a/docs/README.md b/docs/README.md index 546ee6b3..4726c190 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ **SMAPI** is an open-source modding framework and API for [Stardew Valley](https://stardewvalley.net/) that lets you play the game with mods. It's safely installed alongside the game's executable, and -doesn't change any of your game files. It serves eight main purposes: +doesn't change any of your game files. It serves seven main purposes: 1. **Load mods into the game.** _SMAPI loads mods when the game is starting up so they can interact with it. (Code mods aren't @@ -10,14 +10,10 @@ doesn't change any of your game files. It serves eight main purposes: _SMAPI provides APIs and events which let mods interact with the game in ways they otherwise couldn't._ -3. **Rewrite mods for crossplatform compatibility.** +3. **Rewrite mods for compatibility.** _SMAPI rewrites mods' compiled code before loading them so they work on Linux/Mac/Windows without the mods needing to handle differences between the Linux/Mac and Windows versions of the - game._ - -4. **Rewrite mods to update them.** - _SMAPI detects when a mod accesses part of the game that changed in a game update which affects - many mods, and rewrites the mod so it's compatible._ + game. In some cases it also rewrites code broken by a game update so the mod doesn't break._ 5. **Intercept errors and automatically fix saves.** _SMAPI intercepts errors, shows the error info in the SMAPI console, and in most cases @@ -37,8 +33,8 @@ doesn't change any of your game files. It serves eight main purposes: they cause problems._ 8. **Back up your save files.** - _SMAPI automatically creates a daily backup of your saves and keeps ten backups, in case - something goes wrong. (Via the bundled SaveBackup mod.)_ + _SMAPI automatically creates a daily backup of your saves and keeps ten backups (via the bundled + Save Backup mod), in case something goes wrong._ ## Documentation Have questions? Come [ask the community](https://smapi.io/community) to get help from SMAPI @@ -69,7 +65,7 @@ German | ✓ [fully translated](../src/SMAPI/i18n/de.json) Hungarian | ✓ [fully translated](../src/SMAPI/i18n/hu.json) Italian | ✓ [fully translated](../src/SMAPI/i18n/it.json) Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json) -Korean | ❑ not translated +Korean | ✓ [fully translated](../src/SMAPI/i18n/ko.json) Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json) Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json) diff --git a/docs/release-notes.md b/docs/release-notes.md index 185ddc69..97aabd37 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,6 +1,33 @@ ← [README](README.md) # Release notes +## 3.5 +Released 27 April 2020 for Stardew Valley 1.4.1 or later. + +* For players: + * SMAPI now prevents more game errors due to broken items, so you no longer need save editing to remove them. + * Added option to disable console colors. + * Updated compatibility list. + * Improved translations.¹ + +* For the Console Commands mod: + * Commands like `world_setday` now also affect the 'days played' stat, so in-game events/randomization match what you'd get if you played to that date normally (thanks to kdau!). + +* For the web UI: + * Updated the JSON validator/schema for Content Patcher 1.13. + * Fixed rare intermittent "CGI application encountered an error" errors. + +* For modders: + * Added map patching to the content API (via `asset.AsMap()`). + * Added support for using patch helpers with arbitrary data (via `helper.Content.GetPatchHelper`). + * Added `SDate` fields/methods: `SeasonIndex`, `FromDaysSinceStart`, `FromWorldDate`, `ToWorldDate`, and `ToLocaleString` (thanks to kdau!). + * Added `SDate` translations taken from the Lookup Anything mod.¹ + * Fixed asset propagation for certain maps loaded through temporary content managers. This notably fixes unreliable patches to the farmhouse and town maps. + * Fixed asset propagation on Linux/Mac for monster sprites, NPC dialogue, and NPC schedules. + * Fixed asset propagation for NPC dialogue sometimes causing a spouse to skip marriage dialogue or not allow kisses. + +¹ Date format translations were taken from the Lookup Anything mod; thanks to translators FixThisPlz (improved Russian), LeecanIt (added Italian), pomepome (added Japanese), S2SKY (added Korean), Sasara (added German), SteaNN (added Russian), ThomasGabrielDelavault (added Spanish), VincentRoth (added French), Yllelder (improved Spanish), and yuwenlan (added Chinese). Some translations for Korean, Hungarian, and Turkish were derived from the game translations. + ## 3.4.1 Released 24 March 2020 for Stardew Valley 1.4.1 or later. diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 2d58baf0..5b0c6e1f 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -88,8 +88,8 @@ namespace StardewModdingApi.Installer yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files } - /// Handles writing color-coded text to the console. - private ColorfulConsoleWriter ConsoleWriter; + /// Handles writing text to the console. + private IConsoleWriter ConsoleWriter; /********* diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs index aefda9b6..b5bd4600 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs @@ -4,8 +4,8 @@ using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Internal.ConsoleWriting { - /// Provides a wrapper for writing color-coded text to the console. - internal class ColorfulConsoleWriter + /// Writes color-coded text to the console. + internal class ColorfulConsoleWriter : IConsoleWriter { /********* ** Fields @@ -30,8 +30,16 @@ namespace StardewModdingAPI.Internal.ConsoleWriting /// The colors to use for text written to the SMAPI console. public ColorfulConsoleWriter(Platform platform, ColorSchemeConfig colorConfig) { - this.SupportsColor = this.TestColorSupport(); - this.Colors = this.GetConsoleColorScheme(platform, colorConfig); + if (colorConfig.UseScheme == MonitorColorScheme.None) + { + this.SupportsColor = false; + this.Colors = null; + } + else + { + this.SupportsColor = this.TestColorSupport(); + this.Colors = this.GetConsoleColorScheme(platform, colorConfig); + } } /// Write a message line to the log. diff --git a/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs new file mode 100644 index 00000000..fbcf161c --- /dev/null +++ b/src/SMAPI.Internal/ConsoleWriting/IConsoleWriter.cs @@ -0,0 +1,11 @@ +namespace StardewModdingAPI.Internal.ConsoleWriting +{ + /// Writes text to the console. + internal interface IConsoleWriter + { + /// Write a message line to the log. + /// The message to log. + /// The log level. + void WriteLine(string message, ConsoleLogLevel level); + } +} diff --git a/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs index bccb56d7..994ea6a5 100644 --- a/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs +++ b/src/SMAPI.Internal/ConsoleWriting/MonitorColorScheme.cs @@ -10,6 +10,9 @@ namespace StardewModdingAPI.Internal.ConsoleWriting DarkBackground, /// Use darker text colors that look better on a white or light background. - LightBackground + LightBackground, + + /// Disable console color. + None } } diff --git a/src/SMAPI.Internal/SMAPI.Internal.projitems b/src/SMAPI.Internal/SMAPI.Internal.projitems index 7fcebc94..0d583a6d 100644 --- a/src/SMAPI.Internal/SMAPI.Internal.projitems +++ b/src/SMAPI.Internal/SMAPI.Internal.projitems @@ -12,6 +12,7 @@ + \ No newline at end of file diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index 8d6bd759..23c266ea 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -1,4 +1,5 @@ using System.Linq; +using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World @@ -32,6 +33,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World // handle Game1.dayOfMonth = day; + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index 0615afe7..676369fe 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -1,4 +1,5 @@ using System.Linq; +using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World @@ -40,6 +41,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World // handle Game1.currentSeason = season.ToLower(); Game1.setGraphicsForSeason(); + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index 66abd6dc..648830c1 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -1,4 +1,5 @@ using System.Linq; +using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World @@ -32,6 +33,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World // handle Game1.year = year; + Game1.stats.DaysPlayed = (uint)SDate.Now().DaysSinceStart; monitor.Log($"OK, the year is now {Game1.year}.", LogLevel.Info); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index a55d168f..908d4f65 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.4.1", + "Version": "3.5.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.4.1" + "MinimumApiVersion": "3.5.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index 5bf35b5c..cd42459e 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.4.1", + "Version": "3.5.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.4.1" + "MinimumApiVersion": "3.5.0" } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index a9c88c60..45b3673b 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -73,7 +73,7 @@ namespace SMAPI.Tests.Core [nameof(IManifest.Description)] = Sample.String(), [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", - [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}.{Sample.Int()}-{Sample.String()}", [nameof(IManifest.Dependencies)] = new[] { originalDependency }, ["ExtraString"] = Sample.String(), ["ExtraInt"] = Sample.Int() diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index d25a101a..0461952e 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.RegularExpressions; using NUnit.Framework; using StardewModdingAPI.Utilities; +using StardewValley; namespace SMAPI.Tests.Utilities { @@ -81,6 +82,62 @@ namespace SMAPI.Tests.Utilities Assert.Throws(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); } + /**** + ** FromDaysSinceStart + ****/ + [Test(Description = "Assert that FromDaysSinceStart returns the expected date.")] + [TestCase(1, ExpectedResult = "01 spring Y1")] + [TestCase(2, ExpectedResult = "02 spring Y1")] + [TestCase(28, ExpectedResult = "28 spring Y1")] + [TestCase(29, ExpectedResult = "01 summer Y1")] + [TestCase(141, ExpectedResult = "01 summer Y2")] + public string FromDaysSinceStart(int daysSinceStart) + { + // act + return SDate.FromDaysSinceStart(daysSinceStart).ToString(); + } + + [Test(Description = "Assert that FromDaysSinceStart throws an exception if the number of days is invalid.")] + [TestCase(-1)] // day < 0 + [TestCase(0)] // day == 0 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void FromDaysSinceStart_RejectsInvalidValues(int daysSinceStart) + { + // act & assert + Assert.Throws(() => _ = SDate.FromDaysSinceStart(daysSinceStart), "Passing the invalid number of days didn't throw the expected exception."); + } + + /**** + ** From + ****/ + [Test(Description = "Assert that SDate.From constructs the correct instance for a given date.")] + [TestCase(0, ExpectedResult = "01 spring Y1")] + [TestCase(1, ExpectedResult = "02 spring Y1")] + [TestCase(27, ExpectedResult = "28 spring Y1")] + [TestCase(28, ExpectedResult = "01 summer Y1")] + [TestCase(140, ExpectedResult = "01 summer Y2")] + public string From_WorldDate(int totalDays) + { + return SDate.From(new WorldDate { TotalDays = totalDays }).ToString(); + } + + + /**** + ** SeasonIndex + ****/ + [Test(Description = "Assert the numeric index of the season.")] + [TestCase("01 spring Y1", ExpectedResult = 0)] + [TestCase("02 summer Y1", ExpectedResult = 1)] + [TestCase("28 fall Y1", ExpectedResult = 2)] + [TestCase("01 winter Y1", ExpectedResult = 3)] + [TestCase("01 winter Y2", ExpectedResult = 3)] + public int SeasonIndex(string dateStr) + { + // act + return this.GetDate(dateStr).SeasonIndex; + } + + /**** ** DayOfWeek ****/ @@ -119,6 +176,7 @@ namespace SMAPI.Tests.Utilities return this.GetDate(dateStr).DayOfWeek; } + /**** ** DaysSinceStart ****/ @@ -134,6 +192,7 @@ namespace SMAPI.Tests.Utilities return this.GetDate(dateStr).DaysSinceStart; } + /**** ** ToString ****/ @@ -147,6 +206,7 @@ namespace SMAPI.Tests.Utilities return this.GetDate(dateStr).ToString(); } + /**** ** AddDays ****/ @@ -166,6 +226,18 @@ namespace SMAPI.Tests.Utilities return this.GetDate(dateStr).AddDays(addDays).ToString(); } + [Test(Description = "Assert that AddDays throws an exception if the number of days is invalid.")] + [TestCase("01 spring Y1", -1)] + [TestCase("01 summer Y1", -29)] + [TestCase("01 spring Y2", -113)] + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void AddDays_RejectsInvalidValues(string dateStr, int addDays) + { + // act & assert + Assert.Throws(() => _ = this.GetDate(dateStr).AddDays(addDays), "Passing the invalid number of days didn't throw the expected exception."); + } + + /**** ** GetHashCode ****/ @@ -194,6 +266,25 @@ namespace SMAPI.Tests.Utilities } } + + /**** + ** ToWorldDate + ****/ + [Test(Description = "Assert that the WorldDate operator returns the corresponding WorldDate.")] + [TestCase("01 spring Y1", ExpectedResult = 0)] + [TestCase("02 spring Y1", ExpectedResult = 1)] + [TestCase("28 spring Y1", ExpectedResult = 27)] + [TestCase("01 summer Y1", ExpectedResult = 28)] + [TestCase("01 summer Y2", ExpectedResult = 140)] + public int ToWorldDate(string dateStr) + { + return this.GetDate(dateStr).ToWorldDate().TotalDays; + } + + + /**** + ** Operators + ****/ [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] [TestCase(Dates.Now, null, ExpectedResult = false)] [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] diff --git a/src/SMAPI.Web/Program.cs b/src/SMAPI.Web/Program.cs index 5d13cdf3..70082160 100644 --- a/src/SMAPI.Web/Program.cs +++ b/src/SMAPI.Web/Program.cs @@ -18,6 +18,7 @@ namespace StardewModdingAPI.Web .CreateDefaultBuilder(args) .CaptureStartupErrors(true) .UseSetting("detailedErrors", "true") + .UseKestrel().UseIISIntegration() // must be used together; fixes intermittent errors on Azure: https://stackoverflow.com/a/38312175/262123 .UseStartup() .Build() .Run(); diff --git a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index 3101fdf1..179ef42a 100644 --- a/src/SMAPI.Web/wwwroot/SMAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -155,7 +155,7 @@ *********/ "Auto Quality Patch": { "ID": "SilentOak.AutoQualityPatch", - "~2.1.3-unofficial.7 | Status": "AssumeBroken" // runtime errors + "~2.1.3-unofficial.7-mizzion | Status": "AssumeBroken" // runtime errors }, "Fix Dice": { diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index e6cd4e65..f627ab95 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -11,9 +11,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.11.0", + "const": "1.13.0", "@errorMessages": { - "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.11.0'." + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.13.0'." } }, "ConfigSchema": { @@ -268,6 +268,48 @@ "type": "string" } }, + "MapTiles": { + "title": "Map tiles", + "description": "The individual map tiles to add, edit, or remove.", + "type": "array", + "items": { + "type": "object", + "properties": { + "Layer": { + "description": "The map layer name to change.", + "type": "string" + }, + "Position": { + "description": "The tile coordinates to change. You can use the Debug Mode mod to see tile coordinates in-game.", + "$ref": "#/definitions/Position" + }, + "SetTilesheet": { + "title": "Set tilesheet", + "description": "Sets the tilesheet ID for the tile index.", + "type": "string" + }, + "SetIndex": { + "title": "Set tile index", + "description": "Sets the tile index in the tilesheet.", + "type": [ "string", "number" ] + }, + "SetProperties": { + "title": "Set tile properties", + "description": "The properties to set or remove. This is merged into the existing tile properties, if any. To remove a property, set its value to `null` (not \"null\" in quotes).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "Remove": { + "description": "Whether to remove the current tile and all its properties on that layer. If combined with the other fields, a new tile is created from the other fields as if the tile didn't previously exist.", + "type": "boolean" + } + }, + + "required": [ "Layer", "Position" ] + } + }, "When": { "title": "When", "description": "Only apply the patch if the given conditions match.", @@ -335,7 +377,7 @@ } }, "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties" ] + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties", "MapTiles" ] } } } @@ -361,17 +403,37 @@ "type": [ "boolean", "string" ] } }, + "Position": { + "type": "object", + "properties": { + "X": { + "title": "X position", + "description": "The X position, measured in pixels for a texture or tiles for a map. This can contain tokens.", + "type": [ "integer", "string" ], + "minimum:": 0 + }, + "Y": { + "title": "Y position", + "description": "The Y position, measured in pixels for a texture or tiles for a map. This can contain tokens.", + "type": [ "integer", "string" ], + "minimum:": 0 + } + }, + + "required": [ "X", "Y" ], + "additionalProperties": false + }, "Rectangle": { "type": "object", "properties": { "X": { - "title": "X-Coordinate", + "title": "X position", "description": "The X position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.", "type": [ "integer", "string" ], "minimum:": 0 }, "Y": { - "title": "Y-Coordinate", + "title": "Y position", "description": "The Y position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.", "type": [ "integer", "string" ], "minimum:": 0 diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 252db9fe..50b0df10 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI ** Public ****/ /// SMAPI's current semantic version. - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.4.1.7", allowNonStandard: true); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.5.0"); /// The minimum supported version of Stardew Valley. public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.5"); diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs new file mode 100644 index 00000000..f66013ba --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Toolkit.Utilities; +using xTile; +using xTile.Layers; +using xTile.Tiles; + +namespace StardewModdingAPI.Framework.Content +{ + /// Encapsulates access and changes to image content being read from a data file. + internal class AssetDataForMap : AssetData, IAssetDataForMap + { + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The content's locale code, if the content is localized. + /// The normalized asset name being read. + /// The content data being read. + /// Normalizes an asset key to match the cache key. + /// A callback to invoke when the data is replaced (if any). + public AssetDataForMap(string locale, string assetName, Map data, Func getNormalizedPath, Action onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } + + /// Copy layers, tiles, and tilesheets from another map onto the asset. + /// The map from which to copy. + /// The tile area within the source map to copy, or null for the entire source map size. This must be within the bounds of the map. + /// The tile area within the target map to overwrite, or null to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map. + /// Derived from with a few changes: + /// - can be applied directly to the maps when loading, before the location is created; + /// - added support for source/target areas; + /// - added disambiguation if source has a modified version of the same tilesheet, instead of copying tiles into the target tilesheet; + /// - changed to always overwrite tiles within the target area (to avoid edge cases where some tiles are only partly applied); + /// - fixed copying tilesheets (avoid "The specified TileSheet was not created for use with this map" error); + /// - fixed tilesheets not added at the end (via z_ prefix), which can cause crashes in game code which depends on hardcoded tilesheet indexes; + /// - fixed issue where different tilesheets are linked by ID. + /// + public void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null) + { + var target = this.Data; + + // get areas + { + Rectangle sourceBounds = this.GetMapArea(source); + Rectangle targetBounds = this.GetMapArea(target); + sourceArea ??= new Rectangle(0, 0, sourceBounds.Width, sourceBounds.Height); + targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, targetBounds.Width), Math.Min(sourceArea.Value.Height, targetBounds.Height)); + + // validate + if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > sourceBounds.Width || sourceArea.Value.Bottom > sourceBounds.Height) + throw new ArgumentOutOfRangeException(nameof(sourceArea), $"The source area ({sourceArea}) is outside the bounds of the source map ({sourceBounds})."); + if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > targetBounds.Width || targetArea.Value.Bottom > targetBounds.Height) + throw new ArgumentOutOfRangeException(nameof(targetArea), $"The target area ({targetArea}) is outside the bounds of the target map ({targetBounds})."); + if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + throw new InvalidOperationException($"The source area ({sourceArea}) and target area ({targetArea}) must be the same size."); + } + + // apply tilesheets + IDictionary tilesheetMap = new Dictionary(); + foreach (TileSheet sourceSheet in source.TileSheets) + { + // copy tilesheets + TileSheet targetSheet = target.GetTileSheet(sourceSheet.Id); + if (targetSheet == null || this.NormalizeTilesheetPathForComparison(targetSheet.ImageSource) != this.NormalizeTilesheetPathForComparison(sourceSheet.ImageSource)) + { + // change ID if needed so new tilesheets are added after vanilla ones (to avoid errors in hardcoded game logic) + string id = sourceSheet.Id; + if (!id.StartsWith("z_", StringComparison.InvariantCultureIgnoreCase)) + id = $"z_{id}"; + + // change ID if it conflicts with an existing tilesheet + if (target.GetTileSheet(id) != null) + { + int disambiguator = Enumerable.Range(2, int.MaxValue - 1).First(p => target.GetTileSheet($"{id}_{p}") == null); + id = $"{id}_{disambiguator}"; + } + + // add tilesheet + targetSheet = new TileSheet(id, target, sourceSheet.ImageSource, sourceSheet.SheetSize, sourceSheet.TileSize); + for (int i = 0, tileCount = sourceSheet.TileCount; i < tileCount; ++i) + targetSheet.TileIndexProperties[i].CopyFrom(sourceSheet.TileIndexProperties[i]); + target.AddTileSheet(targetSheet); + } + + tilesheetMap[sourceSheet] = targetSheet; + } + + // get layer map + IDictionary layerMap = source.Layers.ToDictionary(p => p, p => target.GetLayer(p.Id)); + + // apply tiles + for (int x = 0; x < sourceArea.Value.Width; x++) + { + for (int y = 0; y < sourceArea.Value.Height; y++) + { + // calculate tile positions + Point sourcePos = new Point(sourceArea.Value.X + x, sourceArea.Value.Y + y); + Point targetPos = new Point(targetArea.Value.X + x, targetArea.Value.Y + y); + + // merge layers + foreach (Layer sourceLayer in source.Layers) + { + // get layer + Layer targetLayer = layerMap[sourceLayer]; + if (targetLayer == null) + { + target.AddLayer(targetLayer = new Layer(sourceLayer.Id, target, target.Layers[0].LayerSize, Layer.m_tileSize)); + layerMap[sourceLayer] = target.GetLayer(sourceLayer.Id); + } + + // copy layer properties + targetLayer.Properties.CopyFrom(sourceLayer.Properties); + + // copy tiles + Tile sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y]; + Tile targetTile; + switch (sourceTile) + { + case StaticTile _: + targetTile = new StaticTile(targetLayer, tilesheetMap[sourceTile.TileSheet], sourceTile.BlendMode, sourceTile.TileIndex); + break; + + case AnimatedTile animatedTile: + { + StaticTile[] tileFrames = new StaticTile[animatedTile.TileFrames.Length]; + for (int frame = 0; frame < animatedTile.TileFrames.Length; ++frame) + { + StaticTile frameTile = animatedTile.TileFrames[frame]; + tileFrames[frame] = new StaticTile(targetLayer, tilesheetMap[frameTile.TileSheet], frameTile.BlendMode, frameTile.TileIndex); + } + targetTile = new AnimatedTile(targetLayer, tileFrames, animatedTile.FrameInterval); + } + break; + + default: // null or unhandled type + targetTile = null; + break; + } + targetTile?.Properties.CopyFrom(sourceTile.Properties); + targetLayer.Tiles[targetPos.X, targetPos.Y] = targetTile; + } + } + } + } + + + /********* + ** Private methods + *********/ + /// Normalize a map tilesheet path for comparison. This value should *not* be used as the actual tilesheet path. + /// The path to normalize. + private string NormalizeTilesheetPathForComparison(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return string.Empty; + + path = PathUtilities.NormalizePathSeparators(path.Trim()); + if (path.StartsWith($"Maps{PathUtilities.PreferredPathSeparator}", StringComparison.OrdinalIgnoreCase)) + path = path.Substring($"Maps{PathUtilities.PreferredPathSeparator}".Length); + if (path.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) + path = path.Substring(0, path.Length - 4); + + return path; + } + + /// Get a rectangle which encompasses all layers for a map. + /// The map to check. + private Rectangle GetMapArea(Map map) + { + // get max map size + int maxWidth = 0; + int maxHeight = 0; + foreach (Layer layer in map.Layers) + { + if (layer.LayerWidth > maxWidth) + maxWidth = layer.LayerWidth; + if (layer.LayerHeight > maxHeight) + maxHeight = layer.LayerHeight; + } + + return new Rectangle(0, 0, maxWidth, maxHeight); + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index 4dbc988c..f00ba124 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; +using xTile; namespace StardewModdingAPI.Framework.Content { @@ -41,6 +42,13 @@ namespace StardewModdingAPI.Framework.Content return new AssetDataForImage(this.Locale, this.AssetName, this.GetData(), this.GetNormalizedPath, this.ReplaceWith); } + /// Get a helper to manipulate the data as a map. + /// The content being read isn't a map. + public IAssetDataForMap AsMap() + { + return new AssetDataForMap(this.Locale, this.AssetName, this.GetData(), this.GetNormalizedPath, this.ReplaceWith); + } + /// Get the data as a given type. /// The expected data type. /// The data can't be converted to . diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 0b1ccc3c..47ef30d4 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -14,6 +14,7 @@ using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; namespace StardewModdingAPI.Framework { @@ -228,16 +229,32 @@ namespace StardewModdingAPI.Framework public IEnumerable InvalidateCache(Func predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary> removedAssets = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + IDictionary removedAssets = new Dictionary(StringComparer.InvariantCultureIgnoreCase); this.ContentManagerLock.InReadLock(() => { + // cached assets foreach (IContentManager contentManager in this.ContentManagers) { foreach (var entry in contentManager.InvalidateCache(predicate, dispose)) { - if (!removedAssets.TryGetValue(entry.Key, out ISet assets)) - removedAssets[entry.Key] = assets = new HashSet(new ObjectReferenceComparer()); - assets.Add(entry.Value); + if (!removedAssets.TryGetValue(entry.Key, out Type type)) + removedAssets[entry.Key] = entry.Value.GetType(); + } + } + + // special case: maps may be loaded through a temporary content manager that's removed while the map is still in use. + // This notably affects the town and farmhouse maps. + if (Game1.locations != null) + { + foreach (GameLocation location in Game1.locations) + { + if (location.map == null || string.IsNullOrWhiteSpace(location.mapPath.Value)) + continue; + + // get map path + string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); + if (!removedAssets.ContainsKey(mapPath) && predicate(mapPath, typeof(Map))) + removedAssets[mapPath] = typeof(Map); } } }); @@ -245,7 +262,7 @@ namespace StardewModdingAPI.Framework // reload core game assets if (removedAssets.Any()) { - IDictionary propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager + IDictionary propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value)); // use an intercepted content manager this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace); } else diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index e9b70845..23e45fd1 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; using StardewValley; @@ -164,6 +165,19 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(predicate).Any(); } + /// Get a patch helper for arbitrary data. + /// The data type. + /// The asset data. + /// The asset name. This is only used for tracking purposes and has no effect on the patch helper. + public IAssetData GetPatchHelper(T data, string assetName = null) + { + if (data == null) + throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); + + assetName ??= $"temp/{Guid.NewGuid():N}"; + return new AssetDataForObject(this.CurrentLocale, assetName, data, this.NormalizeAssetName); + } + /********* ** Private methods diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index e19f02c2..45f4f2d3 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -15,8 +15,8 @@ namespace StardewModdingAPI.Framework /// The name of the module which logs messages using this instance. private readonly string Source; - /// Handles writing color-coded text to the console. - private readonly ColorfulConsoleWriter ConsoleWriter; + /// Handles writing text to the console. + private readonly IConsoleWriter ConsoleWriter; /// Manages access to the console output. private readonly ConsoleInterceptionManager ConsoleInterceptor; diff --git a/src/SMAPI/Framework/Patching/PatchHelper.cs b/src/SMAPI/Framework/Patching/PatchHelper.cs new file mode 100644 index 00000000..4cb436f0 --- /dev/null +++ b/src/SMAPI/Framework/Patching/PatchHelper.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.Patching +{ + /// Provides generic methods for implementing Harmony patches. + internal class PatchHelper + { + /********* + ** Fields + *********/ + /// The interception keys currently being intercepted. + private static readonly HashSet InterceptingKeys = new HashSet(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// Track a method that will be intercepted. + /// The intercept key. + /// Returns false if the method was already marked for interception, else true. + public static bool StartIntercept(string key) + { + return PatchHelper.InterceptingKeys.Add(key); + } + + /// Track a method as no longer being intercepted. + /// The intercept key. + public static void StopIntercept(string key) + { + PatchHelper.InterceptingKeys.Remove(key); + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index b96a0a1a..af227bb9 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -34,6 +34,7 @@ using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Utilities; using StardewValley; using Object = StardewValley.Object; using ThreadState = System.Threading.ThreadState; @@ -178,6 +179,8 @@ namespace StardewModdingAPI.Framework SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + SDate.Translations = this.Translator; + // redirect direct console output if (this.MonitorForGame.WriteToConsole) this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); diff --git a/src/SMAPI/IAssetData.cs b/src/SMAPI/IAssetData.cs index c3021144..8df59e53 100644 --- a/src/SMAPI/IAssetData.cs +++ b/src/SMAPI/IAssetData.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace StardewModdingAPI { @@ -39,6 +39,10 @@ namespace StardewModdingAPI /// The content being read isn't an image. IAssetDataForImage AsImage(); + /// Get a helper to manipulate the data as a map. + /// The content being read isn't a map. + IAssetDataForMap AsMap(); + /// Get the data as a given type. /// The expected data type. /// The data can't be converted to . diff --git a/src/SMAPI/IAssetDataForMap.cs b/src/SMAPI/IAssetDataForMap.cs new file mode 100644 index 00000000..bfaba9ba --- /dev/null +++ b/src/SMAPI/IAssetDataForMap.cs @@ -0,0 +1,18 @@ +using Microsoft.Xna.Framework; +using xTile; + +namespace StardewModdingAPI +{ + /// Encapsulates access and changes to map content being read from a data file. + public interface IAssetDataForMap : IAssetData + { + /********* + ** Public methods + *********/ + /// Copy layers, tiles, and tilesheets from another map onto the asset. + /// The map from which to copy. + /// The tile area within the source map to copy, or null for the entire source map size. This must be within the bounds of the map. + /// The tile area within the target map to overwrite, or null to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map. + void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null); + } +} diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index dd7eb758..2936ecfb 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -64,5 +64,11 @@ namespace StardewModdingAPI /// A predicate matching the assets to invalidate. /// Returns whether any cache entries were invalidated. bool InvalidateCache(Func predicate); + + /// Get a patch helper for arbitrary data. + /// The data type. + /// The asset data. + /// The asset name. This is only used for tracking purposes and has no effect on the patch helper. + IAssetData GetPatchHelper(T data, string assetName = null); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index fb00d609..e9fd620d 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -5,6 +5,7 @@ using System.Linq; using Microsoft.Xna.Framework.Graphics; using Netcode; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Buildings; @@ -892,11 +893,13 @@ namespace StardewModdingAPI.Metadata // doesn't store the text itself. foreach (NPC villager in villagers) { + bool shouldSayMarriageDialogue = villager.shouldSayMarriageDialogue.Value; MarriageDialogueReference[] marriageDialogue = villager.currentMarriageDialogue.ToArray(); villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue villager.resetCurrentDialogue(); + villager.shouldSayMarriageDialogue.Set(shouldSayMarriageDialogue); villager.currentMarriageDialogue.Set(marriageDialogue); } @@ -1039,9 +1042,9 @@ namespace StardewModdingAPI.Metadata /// The path to check. private string[] GetSegments(string path) { - if (path == null) - return new string[0]; - return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + return path != null + ? PathUtilities.GetSegments(path) + : new string[0]; } /// Count the number of segments in a path (e.g. 'a/b' is 2). diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs index 24f97259..1e49826d 100644 --- a/src/SMAPI/Patches/DialogueErrorPatch.cs +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -24,9 +24,6 @@ namespace StardewModdingAPI.Patches /// Simplifies access to private code. private static Reflector Reflection; - /// Whether the getter is currently being intercepted. - private static bool IsInterceptingCurrentDialogue; - /********* ** Accessors @@ -112,12 +109,12 @@ namespace StardewModdingAPI.Patches /// Returns whether to execute the original method. private static bool Before_NPC_CurrentDialogue(NPC __instance, ref Stack __result, MethodInfo __originalMethod) { - if (DialogueErrorPatch.IsInterceptingCurrentDialogue) + const string key = nameof(Before_NPC_CurrentDialogue); + if (!PatchHelper.StartIntercept(key)) return true; try { - DialogueErrorPatch.IsInterceptingCurrentDialogue = true; __result = (Stack)__originalMethod.Invoke(__instance, new object[0]); return false; } @@ -129,7 +126,7 @@ namespace StardewModdingAPI.Patches } finally { - DialogueErrorPatch.IsInterceptingCurrentDialogue = false; + PatchHelper.StopIntercept(key); } } } diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI/Patches/EventErrorPatch.cs index 1dc7e8c3..504d1d2e 100644 --- a/src/SMAPI/Patches/EventErrorPatch.cs +++ b/src/SMAPI/Patches/EventErrorPatch.cs @@ -18,9 +18,6 @@ namespace StardewModdingAPI.Patches /// Writes messages to the console and log file on behalf of the game. private static IMonitor MonitorForGame; - /// Whether the method is currently being intercepted. - private static bool IsIntercepted; - /********* ** Accessors @@ -61,12 +58,12 @@ namespace StardewModdingAPI.Patches /// Returns whether to execute the original method. private static bool Before_GameLocation_CheckEventPrecondition(GameLocation __instance, ref int __result, string precondition, MethodInfo __originalMethod) { - if (EventErrorPatch.IsIntercepted) + const string key = nameof(Before_GameLocation_CheckEventPrecondition); + if (!PatchHelper.StartIntercept(key)) return true; try { - EventErrorPatch.IsIntercepted = true; __result = (int)__originalMethod.Invoke(__instance, new object[] { precondition }); return false; } @@ -78,7 +75,7 @@ namespace StardewModdingAPI.Patches } finally { - EventErrorPatch.IsIntercepted = false; + PatchHelper.StopIntercept(key); } } } diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI/Patches/ObjectErrorPatch.cs index d716b29b..d3b8800a 100644 --- a/src/SMAPI/Patches/ObjectErrorPatch.cs +++ b/src/SMAPI/Patches/ObjectErrorPatch.cs @@ -1,4 +1,6 @@ +using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; using Harmony; using StardewModdingAPI.Framework.Patching; using StardewValley; @@ -33,6 +35,12 @@ namespace StardewModdingAPI.Patches prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_GetDescription)) ); + // object.getDisplayName + harmony.Patch( + original: AccessTools.Method(typeof(SObject), "loadDisplayName"), + prefix: new HarmonyMethod(this.GetType(), nameof(ObjectErrorPatch.Before_Object_loadDisplayName)) + ); + // IClickableMenu.drawToolTip harmony.Patch( original: AccessTools.Method(typeof(IClickableMenu), nameof(IClickableMenu.drawToolTip)), @@ -60,6 +68,37 @@ namespace StardewModdingAPI.Patches return true; } + /// The method to call instead of . + /// The instance being patched. + /// The patched method's return value. + /// The method being wrapped. + /// Returns whether to execute the original method. + private static bool Before_Object_loadDisplayName(SObject __instance, ref string __result, MethodInfo __originalMethod) + { + const string key = nameof(Before_Object_loadDisplayName); + if (!PatchHelper.StartIntercept(key)) + return true; + + try + { + __result = (string)__originalMethod.Invoke(__instance, new object[0]); + return false; + } + catch (TargetInvocationException ex) when (ex.InnerException is KeyNotFoundException) + { + __result = "???"; + return false; + } + catch + { + return true; + } + finally + { + PatchHelper.StopIntercept(key); + } + } + /// The method to call instead of . /// The instance being patched. /// The item for which to draw a tooltip. diff --git a/src/SMAPI/Patches/ScheduleErrorPatch.cs b/src/SMAPI/Patches/ScheduleErrorPatch.cs index a23aa645..799fcb40 100644 --- a/src/SMAPI/Patches/ScheduleErrorPatch.cs +++ b/src/SMAPI/Patches/ScheduleErrorPatch.cs @@ -19,9 +19,6 @@ namespace StardewModdingAPI.Patches /// Writes messages to the console and log file on behalf of the game. private static IMonitor MonitorForGame; - /// Whether the target is currently being intercepted. - private static bool IsIntercepting; - /********* ** Accessors @@ -62,12 +59,12 @@ namespace StardewModdingAPI.Patches /// Returns whether to execute the original method. private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary __result, MethodInfo __originalMethod) { - if (ScheduleErrorPatch.IsIntercepting) + const string key = nameof(Before_NPC_parseMasterSchedule); + if (!PatchHelper.StartIntercept(key)) return true; try { - ScheduleErrorPatch.IsIntercepting = true; __result = (Dictionary)__originalMethod.Invoke(__instance, new object[] { rawData }); return false; } @@ -79,7 +76,7 @@ namespace StardewModdingAPI.Patches } finally { - ScheduleErrorPatch.IsIntercepting = false; + PatchHelper.StopIntercept(key); } } } diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 6473f483..5a2a4969 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -154,8 +154,8 @@ namespace StardewModdingAPI } // load SMAPI - using (SCore core = new SCore(modsPath, writeToConsole)) - core.RunInteractively(); + using SCore core = new SCore(modsPath, writeToConsole); + core.RunInteractively(); } /// Write an error directly to the console and exit. diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 4b9db4f6..aa650085 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -73,6 +73,7 @@ copy all the settings, or you may cause bugs due to overridden changes in future * automatically on Linux or Windows. * - LightBackground: use darker text colors that look better on a white or light background. * - DarkBackground: use lighter text colors that look better on a black or dark background. + * - None: disables all colors, so everything is written in the default terminal color. * * For available color codes, see https://docs.microsoft.com/en-us/dotnet/api/system.consolecolor. * diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index bbecb2e5..5817759b 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -191,6 +191,7 @@ + @@ -279,6 +280,7 @@ + @@ -368,6 +370,7 @@ + @@ -444,6 +447,7 @@ + diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index 0ab37aa0..03230334 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using StardewModdingAPI.Framework; using StardewValley; namespace StardewModdingAPI.Utilities @@ -19,6 +20,9 @@ namespace StardewModdingAPI.Utilities /// The number of days in a season. private readonly int DaysInSeason = 28; + /// The core SMAPI translations. + internal static Translator Translations; + /********* ** Accessors @@ -29,6 +33,10 @@ namespace StardewModdingAPI.Utilities /// The season name. public string Season { get; } + /// The index of the season (where 0 is spring, 1 is summer, 2 is fall, and 3 is winter). + /// This is used in some game calculations (e.g. seasonal game sprites) and methods (e.g. ). + public int SeasonIndex { get; } + /// The year. public int Year { get; } @@ -63,6 +71,30 @@ namespace StardewModdingAPI.Utilities return new SDate(Game1.dayOfMonth, Game1.currentSeason, Game1.year, allowDayZero: true); } + /// Get a date from the number of days after 0 spring Y1. + /// The number of days since 0 spring Y1. + public static SDate FromDaysSinceStart(int daysSinceStart) + { + try + { + return new SDate(0, "spring", 1, allowDayZero: true).AddDays(daysSinceStart); + } + catch (ArithmeticException) + { + throw new ArgumentException($"Invalid daysSinceStart '{daysSinceStart}', must be at least 1."); + } + } + + /// Get a date from a game date instance. + /// The world date. + public static SDate From(WorldDate date) + { + if (date == null) + return null; + + return new SDate(date.DayOfMonth, date.Season, date.Year, allowDayZero: true); + } + /// Get a new date with the given number of days added. /// The number of days to add. /// Returns the resulting date. @@ -92,12 +124,40 @@ namespace StardewModdingAPI.Utilities return new SDate(day, this.Seasons[seasonIndex], year); } - /// Get a string representation of the date. This is mainly intended for debugging or console messages. + /// Get a game date representation of the date. + public WorldDate ToWorldDate() + { + return new WorldDate(this.Year, this.Season, this.Day); + } + + /// Get an untranslated string representation of the date. This is mainly intended for debugging or console messages. public override string ToString() { return $"{this.Day:00} {this.Season} Y{this.Year}"; } + /// Get a translated string representation of the date in the current game locale. + /// Whether to get a string which includes the year number. + public string ToLocaleString(bool withYear = true) + { + // get fallback translation from game + string fallback = Utility.getDateStringFor(this.Day, this.SeasonIndex, this.Year); + if (SDate.Translations == null) + return fallback; + + // get short format + string seasonName = Utility.getSeasonNameFromNumber(this.SeasonIndex); + return SDate.Translations + .Get(withYear ? "generic.date-with-year" : "generic.date", new + { + day = this.Day, + year = this.Year, + season = seasonName, + seasonLowercase = seasonName?.ToLower() + }) + .Default(fallback); + } + /**** ** IEquatable ****/ @@ -200,6 +260,7 @@ namespace StardewModdingAPI.Utilities // initialize this.Day = day; this.Season = season; + this.SeasonIndex = this.GetSeasonIndex(season); this.Year = year; this.DayOfWeek = this.GetDayOfWeek(day); this.DaysSinceStart = this.GetDaysSinceStart(day, season, year); diff --git a/src/SMAPI/i18n/de.json b/src/SMAPI/i18n/de.json index a8b3086f..a8cbd83b 100644 --- a/src/SMAPI/i18n/de.json +++ b/src/SMAPI/i18n/de.json @@ -1,3 +1,10 @@ { - "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)." + // 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 4464316e..563ad42a 100644 --- a/src/SMAPI/i18n/default.json +++ b/src/SMAPI/i18n/default.json @@ -1,5 +1,11 @@ { + // 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}}", + "generic.date-with-year": "{{season}} {{day}} in year {{year}}", "warn.save-broken": "Load save failed, your save file may be broken.You can swap to previous day's save, or restore save files manually with save backups.Backup provided by SMAPI was located at StardewValley/smapi-internal/save-backups", "btn.swap": "Swap", "btn.back": "Back" diff --git a/src/SMAPI/i18n/es.json b/src/SMAPI/i18n/es.json index f5a74dfe..c9843991 100644 --- a/src/SMAPI/i18n/es.json +++ b/src/SMAPI/i18n/es.json @@ -1,3 +1,9 @@ { - "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)." + // 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}}", + "generic.date-with-year": "{{seasonLowercase}} {{day}} del año {{year}}" } diff --git a/src/SMAPI/i18n/fr.json b/src/SMAPI/i18n/fr.json index 6d051025..5969aa20 100644 --- a/src/SMAPI/i18n/fr.json +++ b/src/SMAPI/i18n/fr.json @@ -1,3 +1,9 @@ { - "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)." + // 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}}", + "generic.date-with-year": "{{day}} {{seasonLowercase}} de l'année {{year}}" } diff --git a/src/SMAPI/i18n/hu.json b/src/SMAPI/i18n/hu.json index aa0c7546..785012f4 100644 --- a/src/SMAPI/i18n/hu.json +++ b/src/SMAPI/i18n/hu.json @@ -1,3 +1,9 @@ { - "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)." + // 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}}", + "generic.date-with-year": "{{year}}. év {{season}} {{day}}" } diff --git a/src/SMAPI/i18n/it.json b/src/SMAPI/i18n/it.json index 43493018..3b3351c3 100644 --- a/src/SMAPI/i18n/it.json +++ b/src/SMAPI/i18n/it.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "Contenuto non valido rimosso per prevenire un crash (Guarda la console di SMAPI per maggiori informazioni)." + // 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}}", + "generic.date-with-year": "{{day}} {{season}} dell'anno {{year}}" } diff --git a/src/SMAPI/i18n/ja.json b/src/SMAPI/i18n/ja.json index 9bbc285e..1f814bfa 100644 --- a/src/SMAPI/i18n/ja.json +++ b/src/SMAPI/i18n/ja.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)" + // 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}}日", + "generic.date-with-year": "{{year}}年目 {{season}} {{day}}日" } diff --git a/src/SMAPI/i18n/ko.json b/src/SMAPI/i18n/ko.json new file mode 100644 index 00000000..d5bbffa4 --- /dev/null +++ b/src/SMAPI/i18n/ko.json @@ -0,0 +1,9 @@ +{ + // 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}}", + "generic.date-with-year": "{{year}} 학년 {{season}} {{day}}" +} diff --git a/src/SMAPI/i18n/pt.json b/src/SMAPI/i18n/pt.json index 59273680..e8460922 100644 --- a/src/SMAPI/i18n/pt.json +++ b/src/SMAPI/i18n/pt.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)." + // 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}}", + "generic.date-with-year": "{{season}} {{day}} no ano {{year}}" } diff --git a/src/SMAPI/i18n/ru.json b/src/SMAPI/i18n/ru.json index a6a242fa..002fdbf8 100644 --- a/src/SMAPI/i18n/ru.json +++ b/src/SMAPI/i18n/ru.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "Недопустимое содержимое было удалено, чтобы предотвратить сбой (см. информацию в консоли SMAPI)" + // 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}}-е число", + "generic.date-with-year": "{{season}}, {{day}}-е число, {{year}}-й год" } diff --git a/src/SMAPI/i18n/tr.json b/src/SMAPI/i18n/tr.json index 34229f2b..2a6e83a1 100644 --- a/src/SMAPI/i18n/tr.json +++ b/src/SMAPI/i18n/tr.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "Yanlış paketlenmiş bir içerik, oyunun çökmemesi için yüklenmedi (SMAPI konsol penceresinde detaylı bilgi mevcut)." + // 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}}", + "generic.date-with-year": "{{day}} {{season}} года {{year}}" } diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json index 29a536d7..10ea4442 100644 --- a/src/SMAPI/i18n/zh.json +++ b/src/SMAPI/i18n/zh.json @@ -1,5 +1,11 @@ { + // 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}}日", + "generic.date-with-year": "第{{year}}年{{season}}{{day}}日" "warn.save-broken": "无法加载该存档,存档可能已损坏.你可以切换到前一日的存档,或者手动恢复到备份的存档.SMAPI提供的备份文件位于: StardewValley/smapi-internal/save-backups", "btn.swap": "切换", "btn.back": "返回"