diff options
Diffstat (limited to 'src')
47 files changed, 712 insertions, 62 deletions
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 } - /// <summary>Handles writing color-coded text to the console.</summary> - private ColorfulConsoleWriter ConsoleWriter; + /// <summary>Handles writing text to the console.</summary> + 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 { - /// <summary>Provides a wrapper for writing color-coded text to the console.</summary> - internal class ColorfulConsoleWriter + /// <summary>Writes color-coded text to the console.</summary> + internal class ColorfulConsoleWriter : IConsoleWriter { /********* ** Fields @@ -30,8 +30,16 @@ namespace StardewModdingAPI.Internal.ConsoleWriting /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> 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); + } } /// <summary>Write a message line to the log.</summary> 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 +{ + /// <summary>Writes text to the console.</summary> + internal interface IConsoleWriter + { + /// <summary>Write a message line to the log.</summary> + /// <param name="message">The message to log.</param> + /// <param name="level">The log level.</param> + 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, /// <summary>Use darker text colors that look better on a white or light background.</summary> - LightBackground + LightBackground, + + /// <summary>Disable console color.</summary> + 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 @@ <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorfulConsoleWriter.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorSchemeConfig.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ConsoleLogLevel.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\IConsoleWriter.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\MonitorColorScheme.cs" /> </ItemGroup> </Project>
\ 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 { @@ -82,6 +83,62 @@ namespace SMAPI.Tests.Utilities } /**** + ** 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<ArgumentException>(() => _ = 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 ****/ [Test(Description = "Assert the day of week.")] @@ -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<ArithmeticException>(() => _ = 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<Startup>() .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.sln b/src/SMAPI.sln index 62eaa777..f9c537c4 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -81,7 +81,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web.LegacyRedirects", EndProject 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*{1b3821e6-d030-402c-b3a1-7ca45c2800ea}*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 + SMAPI.Internal\SMAPI.Internal.projitems*{e6da2198-7686-4f1d-b312-4a4dc70884c0}*SharedItemsImports = 5 EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 128e23bd..a898fccd 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.4.1"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.5.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1"); 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 +{ + /// <summary>Encapsulates access and changes to image content being read from a data file.</summary> + internal class AssetDataForMap : AssetData<Map>, IAssetDataForMap + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> + /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> + public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } + + /// <summary>Copy layers, tiles, and tilesheets from another map onto the asset.</summary> + /// <param name="source">The map from which to copy.</param> + /// <param name="sourceArea">The tile area within the source map to copy, or <c>null</c> for the entire source map size. This must be within the bounds of the <paramref name="source"/> map.</param> + /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> + /// <remarks>Derived from <see cref="StardewValley.GameLocation.ApplyMapOverride"/> 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. + /// </remarks> + 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<TileSheet, TileSheet> tilesheetMap = new Dictionary<TileSheet, TileSheet>(); + 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<Layer, Layer> 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 + *********/ + /// <summary>Normalize a map tilesheet path for comparison. This value should *not* be used as the actual tilesheet path.</summary> + /// <param name="path">The path to normalize.</param> + 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; + } + + /// <summary>Get a rectangle which encompasses all layers for a map.</summary> + /// <param name="map">The map to check.</param> + 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<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } + /// <summary>Get a helper to manipulate the data as a map.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't a map.</exception> + public IAssetDataForMap AsMap() + { + return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); + } + /// <summary>Get the data as a given type.</summary> /// <typeparam name="TData">The expected data type.</typeparam> /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> 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<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase); + IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(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<object> assets)) - removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>()); - 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<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager + IDictionary<string, bool> 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(); } + /// <summary>Get a patch helper for arbitrary data.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The asset data.</param> + /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> + public IAssetData GetPatchHelper<T>(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 f630c7fe..44eeabe6 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -15,8 +15,8 @@ namespace StardewModdingAPI.Framework /// <summary>The name of the module which logs messages using this instance.</summary> private readonly string Source; - /// <summary>Handles writing color-coded text to the console.</summary> - private readonly ColorfulConsoleWriter ConsoleWriter; + /// <summary>Handles writing text to the console.</summary> + private readonly IConsoleWriter ConsoleWriter; /// <summary>Manages access to the console output.</summary> 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 +{ + /// <summary>Provides generic methods for implementing Harmony patches.</summary> + internal class PatchHelper + { + /********* + ** Fields + *********/ + /// <summary>The interception keys currently being intercepted.</summary> + private static readonly HashSet<string> InterceptingKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Track a method that will be intercepted.</summary> + /// <param name="key">The intercept key.</param> + /// <returns>Returns false if the method was already marked for interception, else true.</returns> + public static bool StartIntercept(string key) + { + return PatchHelper.InterceptingKeys.Add(key); + } + + /// <summary>Track a method as no longer being intercepted.</summary> + /// <param name="key">The intercept key.</param> + 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 50e6ea1c..de9c955d 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -32,6 +32,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; @@ -176,6 +177,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 /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> IAssetDataForImage AsImage(); + /// <summary>Get a helper to manipulate the data as a map.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't a map.</exception> + IAssetDataForMap AsMap(); + /// <summary>Get the data as a given type.</summary> /// <typeparam name="TData">The expected data type.</typeparam> /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> diff --git a/src/SMAPI/IAssetDataForMap.cs b/src/SMAPI/IAssetDataForMap.cs new file mode 100644 index 00000000..769ca07c --- /dev/null +++ b/src/SMAPI/IAssetDataForMap.cs @@ -0,0 +1,18 @@ +using Microsoft.Xna.Framework; +using xTile; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to map content being read from a data file.</summary> + public interface IAssetDataForMap : IAssetData<Map> + { + /********* + ** Public methods + *********/ + /// <summary>Copy layers, tiles, and tilesheets from another map onto the asset.</summary> + /// <param name="source">The map from which to copy.</param> + /// <param name="sourceArea">The tile area within the source map to copy, or <c>null</c> for the entire source map size. This must be within the bounds of the <paramref name="source"/> map.</param> + /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> + public 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 /// <param name="predicate">A predicate matching the assets to invalidate.</param> /// <returns>Returns whether any cache entries were invalidated.</returns> bool InvalidateCache(Func<IAssetInfo, bool> predicate); + + /// <summary>Get a patch helper for arbitrary data.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The asset data.</param> + /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> + IAssetData GetPatchHelper<T>(T data, string assetName = null); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 83e553ff..0a14086b 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; @@ -891,11 +892,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); } @@ -1037,9 +1040,9 @@ namespace StardewModdingAPI.Metadata /// <param name="path">The path to check.</param> 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]; } /// <summary>Count the number of segments in a path (e.g. 'a/b' is 2).</summary> 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 /// <summary>Simplifies access to private code.</summary> private static Reflector Reflection; - /// <summary>Whether the <see cref="NPC.CurrentDialogue"/> getter is currently being intercepted.</summary> - private static bool IsInterceptingCurrentDialogue; - /********* ** Accessors @@ -112,12 +109,12 @@ 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) { - if (DialogueErrorPatch.IsInterceptingCurrentDialogue) + const string key = nameof(Before_NPC_CurrentDialogue); + if (!PatchHelper.StartIntercept(key)) return true; try { - DialogueErrorPatch.IsInterceptingCurrentDialogue = true; __result = (Stack<Dialogue>)__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 /// <summary>Writes messages to the console and log file on behalf of the game.</summary> private static IMonitor MonitorForGame; - /// <summary>Whether the method is currently being intercepted.</summary> - private static bool IsIntercepted; - /********* ** Accessors @@ -61,12 +58,12 @@ 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) { - 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; } + /// <summary>The method to call instead of <see cref="StardewValley.Object.loadDisplayName"/>.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="__result">The patched method's return value.</param> + /// <param name="__originalMethod">The method being wrapped.</param> + /// <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); + 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); + } + } + /// <summary>The method to call instead of <see cref="IClickableMenu.drawToolTip"/>.</summary> /// <param name="__instance">The instance being patched.</param> /// <param name="hoveredItem">The item for which to draw a tooltip.</param> 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 /// <summary>Writes messages to the console and log file on behalf of the game.</summary> private static IMonitor MonitorForGame; - /// <summary>Whether the target is currently being intercepted.</summary> - private static bool IsIntercepting; - /********* ** Accessors @@ -62,12 +59,12 @@ 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) { - if (ScheduleErrorPatch.IsIntercepting) + const string key = nameof(Before_NPC_parseMasterSchedule); + if (!PatchHelper.StartIntercept(key)) return true; try { - ScheduleErrorPatch.IsIntercepting = true; __result = (Dictionary<int, SchedulePathDescription>)__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 c26ae29a..715c8553 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -143,8 +143,8 @@ namespace StardewModdingAPI } // load SMAPI - using (SCore core = new SCore(modsPath, writeToConsole)) - core.RunInteractively(); + using SCore core = new SCore(modsPath, writeToConsole); + core.RunInteractively(); } /// <summary>Write an error directly to the console and exit.</summary> diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index 57b4f885..a426b0ef 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/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 /// <summary>The number of days in a season.</summary> private readonly int DaysInSeason = 28; + /// <summary>The core SMAPI translations.</summary> + internal static Translator Translations; + /********* ** Accessors @@ -29,6 +33,10 @@ namespace StardewModdingAPI.Utilities /// <summary>The season name.</summary> public string Season { get; } + /// <summary>The index of the season (where 0 is spring, 1 is summer, 2 is fall, and 3 is winter).</summary> + /// <remarks>This is used in some game calculations (e.g. seasonal game sprites) and methods (e.g. <see cref="Utility.getSeasonNameFromNumber"/>).</remarks> + public int SeasonIndex { get; } + /// <summary>The year.</summary> public int Year { get; } @@ -63,6 +71,30 @@ namespace StardewModdingAPI.Utilities return new SDate(Game1.dayOfMonth, Game1.currentSeason, Game1.year, allowDayZero: true); } + /// <summary>Get a date from the number of days after 0 spring Y1.</summary> + /// <param name="daysSinceStart">The number of days since 0 spring Y1.</param> + 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."); + } + } + + /// <summary>Get a date from a game date instance.</summary> + /// <param name="date">The world date.</param> + public static SDate From(WorldDate date) + { + if (date == null) + return null; + + return new SDate(date.DayOfMonth, date.Season, date.Year, allowDayZero: true); + } + /// <summary>Get a new date with the given number of days added.</summary> /// <param name="offset">The number of days to add.</param> /// <returns>Returns the resulting date.</returns> @@ -92,12 +124,40 @@ namespace StardewModdingAPI.Utilities return new SDate(day, this.Seasons[seasonIndex], year); } - /// <summary>Get a string representation of the date. This is mainly intended for debugging or console messages.</summary> + /// <summary>Get a game date representation of the date.</summary> + public WorldDate ToWorldDate() + { + return new WorldDate(this.Year, this.Season, this.Day); + } + + /// <summary>Get an untranslated string representation of the date. This is mainly intended for debugging or console messages.</summary> public override string ToString() { return $"{this.Day:00} {this.Season} Y{this.Year}"; } + /// <summary>Get a translated string representation of the date in the current game locale.</summary> + /// <param name="withYear">Whether to get a string which includes the year number.</param> + 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 5a3e4a6e..7a3d3ed5 100644 --- a/src/SMAPI/i18n/default.json +++ b/src/SMAPI/i18n/default.json @@ -1,3 +1,9 @@ { - "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)." + // 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}}" } 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 9c0e0c21..cdbe3b74 100644 --- a/src/SMAPI/i18n/zh.json +++ b/src/SMAPI/i18n/zh.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}}日" } |