diff options
Diffstat (limited to 'src')
36 files changed, 1300 insertions, 330 deletions
diff --git a/src/GlobalAssemblyInfo.cs b/src/GlobalAssemblyInfo.cs index 93ff68ec..4344dbce 100644 --- a/src/GlobalAssemblyInfo.cs +++ b/src/GlobalAssemblyInfo.cs @@ -2,5 +2,5 @@ using System.Runtime.InteropServices; [assembly: ComVisible(false)] -[assembly: AssemblyVersion("1.13.1.0")] -[assembly: AssemblyFileVersion("1.13.1.0")]
\ No newline at end of file +[assembly: AssemblyVersion("1.14.0.0")] +[assembly: AssemblyFileVersion("1.14.0.0")]
\ No newline at end of file diff --git a/src/StardewModdingAPI.Installer/InteractiveInstaller.cs b/src/StardewModdingAPI.Installer/InteractiveInstaller.cs index 01f7a01f..efad0a3e 100644 --- a/src/StardewModdingAPI.Installer/InteractiveInstaller.cs +++ b/src/StardewModdingAPI.Installer/InteractiveInstaller.cs @@ -85,6 +85,7 @@ namespace StardewModdingApi.Installer yield return GetInstallPath("steam_appid.txt"); // Linux/Mac only + yield return GetInstallPath("libgdiplus.dylib"); yield return GetInstallPath("StardewModdingAPI"); yield return GetInstallPath("StardewModdingAPI.exe.mdb"); yield return GetInstallPath("System.Numerics.dll"); @@ -158,7 +159,10 @@ namespace StardewModdingApi.Installer ****/ if (!packageDir.Exists) { - this.PrintError($"The 'internal/{platform}' package folder is missing (should be at {packageDir})."); + this.PrintError(platform == Platform.Windows && packageDir.FullName.Contains(Path.GetTempPath()) && packageDir.FullName.Contains(".zip") + ? "The installer is missing some files. It looks like you're running the installer from inside the downloaded zip; make sure you unzip the downloaded file first, then run the installer from the unzipped folder." + : $"The 'internal/{platform}' package folder is missing (should be at {packageDir})." + ); Console.ReadLine(); return; } diff --git a/src/StardewModdingAPI.Installer/readme.txt b/src/StardewModdingAPI.Installer/readme.txt index 4756099e..cf6090c4 100644 --- a/src/StardewModdingAPI.Installer/readme.txt +++ b/src/StardewModdingAPI.Installer/readme.txt @@ -13,13 +13,7 @@ SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separately. - -To install: - - Windows: double-click install.exe. - - Linux or Mac: open a terminal and run `mono install.exe`. - - Need help? See: - - Install guide: http://canimod.com/guides/using-mods#installing-smapi - - Troubleshooting: http://canimod.com/guides/smapi-faq#troubleshooting + - Install guide: http://canimod.com/for-players/install-smapi + - Troubleshooting: http://canimod.com/for-players/faqs#troubleshooting - Ask for help: https://discord.gg/kH55QXP diff --git a/src/StardewModdingAPI.Tests/ModResolverTests.cs b/src/StardewModdingAPI.Tests/ModResolverTests.cs index 8cf5a29e..23aeba64 100644 --- a/src/StardewModdingAPI.Tests/ModResolverTests.cs +++ b/src/StardewModdingAPI.Tests/ModResolverTests.cs @@ -13,6 +13,7 @@ using StardewModdingAPI.Tests.Framework; namespace StardewModdingAPI.Tests { + /// <summary>Unit tests for <see cref="ModResolver"/>.</summary> [TestFixture] public class ModResolverTests { @@ -71,9 +72,7 @@ namespace StardewModdingAPI.Tests [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", -#if EXPERIMENTAL [nameof(IManifest.Dependencies)] = new[] { originalDependency }, -#endif ["ExtraString"] = Sample.String(), ["ExtraInt"] = Sample.Int() }; @@ -110,11 +109,9 @@ namespace StardewModdingAPI.Tests Assert.AreEqual(original["ExtraString"], mod.Manifest.ExtraFields["ExtraString"], "The manifest's extra fields should contain an 'ExtraString' value."); Assert.AreEqual(original["ExtraInt"], mod.Manifest.ExtraFields["ExtraInt"], "The manifest's extra fields should contain an 'ExtraInt' value."); -#if EXPERIMENTAL Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); -#endif } /**** @@ -216,7 +213,6 @@ namespace StardewModdingAPI.Tests // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. } -#if EXPERIMENTAL /**** ** ProcessDependencies ****/ @@ -249,6 +245,20 @@ namespace StardewModdingAPI.Tests Assert.AreSame(modC.Object, mods[2], "The load order unexpectedly changed with no dependencies."); } + [Test(Description = "Assert that processing dependencies skips mods that have already failed without calling any other properties.")] + public void ProcessDependencies_Skips_Failed() + { + // arrange + Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + new ModResolver().ProcessDependencies(new[] { mock.Object }); + + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } + [Test(Description = "Assert that simple dependencies are reordered correctly.")] public void ProcessDependencies_Reorders_SimpleDependencies() { @@ -345,8 +355,29 @@ namespace StardewModdingAPI.Tests modD.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod D was expected to fail since it's part of a dependency loop."); modE.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod E was expected to fail since it's part of a dependency loop."); } -#endif + [Test(Description = "Assert that dependencies are sorted correctly even if some of the mods failed during metadata loading.")] + public void ProcessDependencies_WithSomeFailedMods_Succeeds() + { + // arrange + // A ◀── B ◀── C D (failed) + Mock<IModMetadata> modA = this.GetMetadataForDependencyTest("Mod A"); + Mock<IModMetadata> modB = this.GetMetadataForDependencyTest("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadataForDependencyTest("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); + Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict); + modD.Setup(p => p.Manifest).Returns<IManifest>(null); + modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object }).ToArray(); + + // assert + Assert.AreEqual(4, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modD.Object, mods[0], "The load order is incorrect: mod D should be first since it was already failed."); + Assert.AreSame(modA.Object, mods[1], "The load order is incorrect: mod A should be second since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[2], "The load order is incorrect: mod B should be third since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[3], "The load order is incorrect: mod C should be fourth since it needs mod B, and is needed by mod D."); + } /********* ** Private methods @@ -368,7 +399,6 @@ namespace StardewModdingAPI.Tests return manifest; } -#if EXPERIMENTAL /// <summary>Get a randomised basic manifest.</summary> /// <param name="uniqueID">The mod's name and unique ID.</param> /// <param name="dependencies">The dependencies this mod requires.</param> @@ -395,6 +425,5 @@ namespace StardewModdingAPI.Tests } return mod; } -#endif } } diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj index c84adbd7..3818ec9c 100644 --- a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -48,6 +48,7 @@ <Compile Include="..\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> + <Compile Include="TranslationTests.cs" /> <Compile Include="ModResolverTests.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> <Compile Include="Framework\Sample.cs" /> @@ -61,5 +62,6 @@ <Name>StardewModdingAPI</Name> </ProjectReference> </ItemGroup> + <Import Project="$(SolutionDir)\crossplatform.targets" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> </Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Tests/TranslationTests.cs b/src/StardewModdingAPI.Tests/TranslationTests.cs new file mode 100644 index 00000000..6a430aa7 --- /dev/null +++ b/src/StardewModdingAPI.Tests/TranslationTests.cs @@ -0,0 +1,348 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; +using StardewModdingAPI.Framework; +using StardewValley; + +namespace StardewModdingAPI.Tests +{ + /// <summary>Unit tests for <see cref="TranslationHelper"/> and <see cref="Translation"/>.</summary> + [TestFixture] + public class TranslationTests + { + /********* + ** Data + *********/ + /// <summary>Sample translation text for unit tests.</summary> + public static string[] Samples = { null, "", " ", "boop", " boop " }; + + + /********* + ** Unit tests + *********/ + /**** + ** Translation helper + ****/ + [Test(Description = "Assert that the translation helper correctly handles no translations.")] + public void Helper_HandlesNoTranslations() + { + // arrange + var data = new Dictionary<string, IDictionary<string, string>>(); + + // act + ITranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + Translation translation = helper.Get("key"); + + // assert + Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value."); + Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value."); + Assert.IsNotNull(helper.GetTranslations(), "The full list of translations is unexpectedly null."); + Assert.AreEqual(0, helper.GetTranslations().Count, "The full list of translations is unexpectedly not empty."); + + Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation."); + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value."); + } + + [Test(Description = "Assert that the translation helper returns the expected translations correctly.")] + public void Helper_GetTranslations_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary<string, IDictionary<string, string>>(); + TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + actual[locale] = helper.GetTranslations(); + } + + // assert + foreach (string locale in expected.Keys) + { + Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); + Assert.That(actual[locale], Is.EquivalentTo(expected[locale]), $"The translations for {locale} don't match the expected values."); + } + } + + [Test(Description = "Assert that the translations returned by the helper has the expected text.")] + public void Helper_Get_ReturnsExpectedText() + { + // arrange + var data = this.GetSampleData(); + var expected = this.GetExpectedTranslations(); + + // act + var actual = new Dictionary<string, IDictionary<string, string>>(); + TranslationHelper helper = new TranslationHelper("ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + actual[locale] = new Dictionary<string, string>(); + foreach (string key in expected[locale].Keys) + actual[locale][key] = helper.Get(key); + } + + // assert + foreach (string locale in expected.Keys) + { + Assert.IsNotNull(actual[locale], $"The translations for {locale} is unexpectedly null."); + Assert.That(actual[locale], Is.EquivalentTo(expected[locale]), $"The translations for {locale} don't match the expected values."); + } + } + + /**** + ** Translation + ****/ + [Test(Description = "Assert that HasValue returns the expected result for various inputs.")] + [TestCase(null, ExpectedResult = false)] + [TestCase("", ExpectedResult = false)] + [TestCase(" ", ExpectedResult = true)] + [TestCase("boop", ExpectedResult = true)] + [TestCase(" boop ", ExpectedResult = true)] + public bool Translation_HasValue(string text) + { + return new Translation("ModName", "pt-BR", "key", text).HasValue(); + } + + [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] + public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input."); + } + + [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] + public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, (string)translation, "The translation returned an unexpected value given a valid input."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), (string)translation, "The translation returned an unexpected value given a null or empty input."); + } + + [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] + public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).UsePlaceholder(value); + + // assert + if (translation.HasValue()) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid input."); + else if (!value) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder disabled."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder enabled."); + } + + [Test(Description = "Assert that the translation's Assert method throws the expected exception.")] + public void Translation_Assert([ValueSource(nameof(TranslationTests.Samples))] string text) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text); + + // assert + if (translation.HasValue()) + Assert.That(() => translation.Assert(), Throws.Nothing, "The assert unexpected threw an exception for a valid input."); + else + Assert.That(() => translation.Assert(), Throws.Exception.TypeOf<KeyNotFoundException>(), "The assert didn't throw an exception for invalid input."); + } + + [Test(Description = "Assert that the translation returns the expected text after setting the default.")] + public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default) + { + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Default(@default); + + // assert + if (!string.IsNullOrEmpty(text)) + Assert.AreEqual(text, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + else if (!string.IsNullOrEmpty(@default)) + Assert.AreEqual(@default, translation.ToString(), "The translation returned an unexpected value given a null or empty base text, but valid default."); + else + Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty base and default text."); + } + + /**** + ** Translation tokens + ****/ + [Test(Description = "Assert that multiple translation tokens are replaced correctly regardless of the token structure.")] + public void Translation_Tokens([Values("anonymous object", "class", "IDictionary<string, object>", "IDictionary<string, string>")] string structure) + { + // arrange + string start = Guid.NewGuid().ToString("N"); + string middle = Guid.NewGuid().ToString("N"); + string end = Guid.NewGuid().ToString("N"); + const string input = "{{start}} tokens are properly replaced (including {{middle}} {{ MIDdlE}}) {{end}}"; + string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", input); + switch (structure) + { + case "anonymous object": + translation = translation.Tokens(new { start, middle, end }); + break; + + case "class": + translation = translation.Tokens(new TokenModel { Start = start, Middle = middle, End = end }); + break; + + case "IDictionary<string, object>": + translation = translation.Tokens(new Dictionary<string, object> { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; + + case "IDictionary<string, string>": + translation = translation.Tokens(new Dictionary<string, string> { ["start"] = start, ["middle"] = middle, ["end"] = end }); + break; + + default: + throw new NotSupportedException($"Unknown structure '{structure}'."); + } + + // assert + Assert.AreEqual(expected, translation.ToString(), "The translation returned an unexpected text."); + } + + [Test(Description = "Assert that the translation can replace tokens in all valid formats.")] + [TestCase("{{value}}", "value")] + [TestCase("{{ value }}", "value")] + [TestCase("{{value }}", "value")] + [TestCase("{{ the_value }}", "the_value")] + [TestCase("{{ the.value_here }}", "the.value_here")] + [TestCase("{{ the_value-here.... }}", "the_value-here....")] + [TestCase("{{ tHe_vALuE-HEre.... }}", "tHe_vALuE-HEre....")] + public void Translation_Tokens_ValidFormats(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); + + // assert + Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + } + + [Test(Description = "Assert that translation tokens are case-insensitive and surrounding-whitespace-insensitive.")] + [TestCase("{{value}}", "value")] + [TestCase("{{VaLuE}}", "vAlUe")] + [TestCase("{{VaLuE }}", " vAlUe")] + public void Translation_Tokens_KeysAreNormalised(string text, string key) + { + // arrange + string value = Guid.NewGuid().ToString("N"); + + // act + Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); + + // assert + Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); + } + + + /********* + ** Private methods + *********/ + /// <summary>Set a translation helper's locale and assert that it was set correctly.</summary> + /// <param name="helper">The translation helper to change.</param> + /// <param name="locale">The expected locale.</param> + /// <param name="localeEnum">The expected game language code.</param> + private void AssertSetLocale(TranslationHelper helper, string locale, LocalizedContentManager.LanguageCode localeEnum) + { + helper.SetLocale(locale, localeEnum); + Assert.AreEqual(locale, helper.Locale, "The locale doesn't match the input value."); + Assert.AreEqual(localeEnum, helper.LocaleEnum, "The locale enum doesn't match the input value."); + } + + /// <summary>Get sample raw translations to input.</summary> + private IDictionary<string, IDictionary<string, string>> GetSampleData() + { + return new Dictionary<string, IDictionary<string, string>> + { + ["default"] = new Dictionary<string, string> + { + ["key A"] = "default A", + ["key C"] = "default C" + }, + ["en"] = new Dictionary<string, string> + { + ["key A"] = "en A", + ["key B"] = "en B" + }, + ["en-US"] = new Dictionary<string, string>(), + ["zzz"] = new Dictionary<string, string> + { + ["key A"] = "zzz A" + } + }; + } + + /// <summary>Get the expected translation output given <see cref="TranslationTests.GetSampleData"/>, based on the expected locale fallback.</summary> + private IDictionary<string, IDictionary<string, string>> GetExpectedTranslations() + { + return new Dictionary<string, IDictionary<string, string>> + { + ["default"] = new Dictionary<string, string> + { + ["key A"] = "default A", + ["key C"] = "default C" + }, + ["en"] = new Dictionary<string, string> + { + ["key A"] = "en A", + ["key B"] = "en B", + ["key C"] = "default C" + }, + ["en-us"] = new Dictionary<string, string> + { + ["key A"] = "en A", + ["key B"] = "en B", + ["key C"] = "default C" + }, + ["zzz"] = new Dictionary<string, string> + { + ["key A"] = "zzz A", + ["key C"] = "default C" + } + }; + } + + /// <summary>Get the default placeholder text when a translation is missing.</summary> + /// <param name="key">The translation key.</param> + private string GetPlaceholderText(string key) + { + return string.Format(Translation.PlaceholderText, key); + } + + + /********* + ** Test models + *********/ + /// <summary>A model used to test token support.</summary> + private class TokenModel + { + /// <summary>A sample token property.</summary> + public string Start { get; set; } + + /// <summary>A sample token property.</summary> + public string Middle { get; set; } + + /// <summary>A sample token field.</summary> + public string End; + } + } +} diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs index 1c60da82..4e53387e 100644 --- a/src/StardewModdingAPI/Constants.cs +++ b/src/StardewModdingAPI/Constants.cs @@ -33,10 +33,10 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(1, 13, 1); + public static ISemanticVersion ApiVersion { get; } = new SemanticVersion(1, 14, 0); // alpha-{DateTime.UtcNow:yyyyMMddHHmm} /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.2.26"); + public static ISemanticVersion MinimumGameVersion { get; } = new SemanticVersion("1.2.30"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; diff --git a/src/StardewModdingAPI/Context.cs b/src/StardewModdingAPI/Context.cs index 6bc5ae56..6c5ae40e 100644 --- a/src/StardewModdingAPI/Context.cs +++ b/src/StardewModdingAPI/Context.cs @@ -1,4 +1,5 @@ -using StardewValley; +using StardewModdingAPI.Events; +using StardewValley; using StardewValley.Menus; namespace StardewModdingAPI @@ -15,6 +16,12 @@ namespace StardewModdingAPI /// <summary>Whether the player has loaded a save and the world has finished initialising.</summary> public static bool IsWorldReady { get; internal set; } + /// <summary>Whether the player is free to move around (e.g. save is loaded, no menu is displayed, no cutscene is in progress, etc).</summary> + public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.player.CanMove && !Game1.dialogueUp && !Game1.eventUp; + + /// <summary>Whether the game is currently running the draw loop. This isn't relevant to most mods, since you should use <see cref="GraphicsEvents.OnPostRenderEvent"/> to draw to the screen.</summary> + public static bool IsInDrawLoop { get; internal set; } + /**** ** Internal ****/ @@ -23,8 +30,5 @@ namespace StardewModdingAPI /// <summary>Whether the game is currently writing to the save file.</summary> internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something - - /// <summary>Whether the game is currently running the draw loop.</summary> - internal static bool IsInDrawLoop { get; set; } } } diff --git a/src/StardewModdingAPI/Events/ContentEvents.cs b/src/StardewModdingAPI/Events/ContentEvents.cs index 0dcd2cc6..8fa9ae3c 100644 --- a/src/StardewModdingAPI/Events/ContentEvents.cs +++ b/src/StardewModdingAPI/Events/ContentEvents.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework; namespace StardewModdingAPI.Events @@ -19,6 +20,9 @@ namespace StardewModdingAPI.Events /// <summary>The mods using the experimental API for which a warning has been raised.</summary> private static readonly HashSet<string> WarnedMods = new HashSet<string>(); + /// <summary>The backing field for <see cref="AfterAssetLoaded"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<IContentEventHelper> _AfterAssetLoaded; /********* ** Events @@ -32,7 +36,15 @@ namespace StardewModdingAPI.Events #else internal #endif - static event EventHandler<IContentEventHelper> AfterAssetLoaded; + static event EventHandler<IContentEventHelper> AfterAssetLoaded + { + add + { + ContentEvents.RaiseContentExperimentalWarning(); + ContentEvents._AfterAssetLoaded += value; + } + remove => ContentEvents._AfterAssetLoaded -= value; + } /********* @@ -61,30 +73,21 @@ namespace StardewModdingAPI.Events /// <param name="contentHelper">Encapsulates access and changes to content being read from a data file.</param> internal static void InvokeAfterAssetLoaded(IMonitor monitor, IContentEventHelper contentHelper) { - if (ContentEvents.AfterAssetLoaded != null) - { - Delegate[] handlers = ContentEvents.AfterAssetLoaded.GetInvocationList(); - ContentEvents.RaiseDeprecationWarning(handlers); - monitor.SafelyRaiseGenericEvent($"{nameof(ContentEvents)}.{nameof(ContentEvents.AfterAssetLoaded)}", handlers, null, contentHelper); - } + monitor.SafelyRaiseGenericEvent($"{nameof(ContentEvents)}.{nameof(ContentEvents.AfterAssetLoaded)}", ContentEvents._AfterAssetLoaded?.GetInvocationList(), null, contentHelper); } /********* ** Private methods *********/ - /// <summary>Raise a 'experimental API' warning for each mod using the content API.</summary> - /// <param name="handlers">The event handlers.</param> - private static void RaiseDeprecationWarning(Delegate[] handlers) + /// <summary>Raise an 'experimental API' warning for a mod using the content API.</summary> + private static void RaiseContentExperimentalWarning() { - foreach (Delegate handler in handlers) + string modName = ContentEvents.ModRegistry.GetModFromStack() ?? "An unknown mod"; + if (!ContentEvents.WarnedMods.Contains(modName)) { - string modName = ContentEvents.ModRegistry.GetModFrom(handler) ?? "An unknown mod"; - if (!ContentEvents.WarnedMods.Contains(modName)) - { - ContentEvents.WarnedMods.Add(modName); - ContentEvents.Monitor.Log($"{modName} used the undocumented and experimental content API, which may change or be removed without warning.", LogLevel.Warn); - } + ContentEvents.WarnedMods.Add(modName); + ContentEvents.Monitor.Log($"{modName} used the undocumented and experimental content API, which may change or be removed without warning.", LogLevel.Warn); } } } diff --git a/src/StardewModdingAPI/Events/GameEvents.cs b/src/StardewModdingAPI/Events/GameEvents.cs index 4f9ce7a7..8e3cf662 100644 --- a/src/StardewModdingAPI/Events/GameEvents.cs +++ b/src/StardewModdingAPI/Events/GameEvents.cs @@ -1,6 +1,8 @@ using System; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework; +#pragma warning disable 618 // Suppress obsolete-symbol errors in this file. Since several events are marked obsolete, this produces unnecessary warnings. namespace StardewModdingAPI.Events { /// <summary>Events raised when the game changes state.</summary> @@ -12,6 +14,22 @@ namespace StardewModdingAPI.Events /// <summary>Manages deprecation warnings.</summary> private static DeprecationManager DeprecationManager; + /// <summary>The backing field for <see cref="Initialize"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler _Initialize; + + /// <summary>The backing field for <see cref="LoadContent"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler _LoadContent; + + /// <summary>The backing field for <see cref="GameLoaded"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler _GameLoaded; + + /// <summary>The backing field for <see cref="FirstUpdateTick"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler _FirstUpdateTick; + /********* ** Events @@ -24,19 +42,51 @@ namespace StardewModdingAPI.Events /// <summary>Raised during launch after configuring XNA or MonoGame. The game window hasn't been opened by this point. Called after <see cref="Microsoft.Xna.Framework.Game.Initialize"/>.</summary> [Obsolete("The " + nameof(Mod) + "." + nameof(Mod.Entry) + " method is now called after the " + nameof(GameEvents.Initialize) + " event, so any contained logic can be done directly in " + nameof(Mod.Entry) + ".")] - public static event EventHandler Initialize; + public static event EventHandler Initialize + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.Initialize)}", "1.10", DeprecationLevel.Info); + GameEvents._Initialize += value; + } + remove => GameEvents._Initialize -= value; + } /// <summary>Raised before XNA loads or reloads graphics resources. Called during <see cref="Microsoft.Xna.Framework.Game.LoadContent"/>.</summary> [Obsolete("The " + nameof(Mod) + "." + nameof(Mod.Entry) + " method is now called after the " + nameof(GameEvents.LoadContent) + " event, so any contained logic can be done directly in " + nameof(Mod.Entry) + ".")] - public static event EventHandler LoadContent; + public static event EventHandler LoadContent + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}", "1.10", DeprecationLevel.Info); + GameEvents._LoadContent += value; + } + remove => GameEvents._LoadContent -= value; + } /// <summary>Raised during launch after configuring Stardew Valley, loading it into memory, and opening the game window. The window is still blank by this point.</summary> [Obsolete("The " + nameof(Mod) + "." + nameof(Mod.Entry) + " method is now called after the game loads, so any contained logic can be done directly in " + nameof(Mod.Entry) + ".")] - public static event EventHandler GameLoaded; + public static event EventHandler GameLoaded + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}", "1.12", DeprecationLevel.Info); + GameEvents._GameLoaded += value; + } + remove => GameEvents._GameLoaded -= value; + } /// <summary>Raised during the first game update tick.</summary> [Obsolete("The " + nameof(Mod) + "." + nameof(Mod.Entry) + " method is now called after the game loads, so any contained logic can be done directly in " + nameof(Mod.Entry) + ".")] - public static event EventHandler FirstUpdateTick; + public static event EventHandler FirstUpdateTick + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}", "1.12", DeprecationLevel.Info); + GameEvents._FirstUpdateTick += value; + } + remove => GameEvents._FirstUpdateTick -= value; + } /// <summary>Raised when the game updates its state (≈60 times per second).</summary> public static event EventHandler UpdateTick; @@ -74,62 +124,30 @@ namespace StardewModdingAPI.Events /// <param name="monitor">Encapsulates logging and monitoring.</param> internal static void InvokeInitialize(IMonitor monitor) { - // notify SMAPI monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.InitializeInternal)}", GameEvents.InitializeInternal?.GetInvocationList()); - - // notify mods - if (GameEvents.Initialize == null) - return; - string name = $"{nameof(GameEvents)}.{nameof(GameEvents.Initialize)}"; - Delegate[] handlers = GameEvents.Initialize.GetInvocationList(); - GameEvents.DeprecationManager.WarnForEvent(handlers, name, "1.10", DeprecationLevel.Info); - monitor.SafelyRaisePlainEvent(name, handlers); + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.Initialize)}", GameEvents._Initialize?.GetInvocationList()); } /// <summary>Raise a <see cref="LoadContent"/> event.</summary> /// <param name="monitor">Encapsulates logging and monitoring.</param> internal static void InvokeLoadContent(IMonitor monitor) { - if (GameEvents.LoadContent == null) - return; - - string name = $"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}"; - Delegate[] handlers = GameEvents.LoadContent.GetInvocationList(); - - GameEvents.DeprecationManager.WarnForEvent(handlers, name, "1.10", DeprecationLevel.Info); - monitor.SafelyRaisePlainEvent(name, handlers); + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}", GameEvents._LoadContent?.GetInvocationList()); } /// <summary>Raise a <see cref="GameLoaded"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> internal static void InvokeGameLoaded(IMonitor monitor) { - // notify SMAPI monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoadedInternal)}", GameEvents.GameLoadedInternal?.GetInvocationList()); - - // notify mods - if (GameEvents.GameLoaded == null) - return; - - string name = $"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}"; - Delegate[] handlers = GameEvents.GameLoaded.GetInvocationList(); - - GameEvents.DeprecationManager.WarnForEvent(handlers, name, "1.12", DeprecationLevel.Info); - monitor.SafelyRaisePlainEvent(name, handlers); + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}", GameEvents._GameLoaded?.GetInvocationList()); } /// <summary>Raise a <see cref="FirstUpdateTick"/> event.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> internal static void InvokeFirstUpdateTick(IMonitor monitor) { - if (GameEvents.FirstUpdateTick == null) - return; - - string name = $"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}"; - Delegate[] handlers = GameEvents.FirstUpdateTick.GetInvocationList(); - - GameEvents.DeprecationManager.WarnForEvent(handlers, name, "1.12", DeprecationLevel.Info); - monitor.SafelyRaisePlainEvent(name, handlers); + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}", GameEvents._FirstUpdateTick?.GetInvocationList()); } /// <summary>Raise an <see cref="UpdateTick"/> event.</summary> diff --git a/src/StardewModdingAPI/Events/PlayerEvents.cs b/src/StardewModdingAPI/Events/PlayerEvents.cs index b02ebfec..37649fee 100644 --- a/src/StardewModdingAPI/Events/PlayerEvents.cs +++ b/src/StardewModdingAPI/Events/PlayerEvents.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Framework; using StardewValley; using SFarmer = StardewValley.Farmer; +#pragma warning disable 618 // Suppress obsolete-symbol errors in this file. Since several events are marked obsolete, this produces unnecessary warnings. namespace StardewModdingAPI.Events { /// <summary>Events raised when the player data changes.</summary> @@ -16,17 +18,41 @@ namespace StardewModdingAPI.Events /// <summary>Manages deprecation warnings.</summary> private static DeprecationManager DeprecationManager; + /// <summary>The backing field for <see cref="LoadedGame"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsLoadedGameChanged> _LoadedGame; + + /// <summary>The backing field for <see cref="FarmerChanged"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsFarmerChanged> _FarmerChanged; + /********* ** Events *********/ /// <summary>Raised after the player loads a saved game.</summary> [Obsolete("Use " + nameof(SaveEvents) + "." + nameof(SaveEvents.AfterLoad) + " instead")] - public static event EventHandler<EventArgsLoadedGameChanged> LoadedGame; + public static event EventHandler<EventArgsLoadedGameChanged> LoadedGame + { + add + { + PlayerEvents.DeprecationManager.Warn($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}", "1.6", DeprecationLevel.Info); + PlayerEvents._LoadedGame += value; + } + remove => PlayerEvents._LoadedGame -= value; + } /// <summary>Raised after the game assigns a new player character. This happens just before <see cref="LoadedGame"/>; it's unclear how this would happen any other time.</summary> [Obsolete("should no longer be used")] - public static event EventHandler<EventArgsFarmerChanged> FarmerChanged; + public static event EventHandler<EventArgsFarmerChanged> FarmerChanged + { + add + { + PlayerEvents.DeprecationManager.Warn($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}", "1.6", DeprecationLevel.Info); + PlayerEvents._FarmerChanged += value; + } + remove => PlayerEvents._FarmerChanged -= value; + } /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> public static event EventHandler<EventArgsInventoryChanged> InventoryChanged; @@ -50,14 +76,7 @@ namespace StardewModdingAPI.Events /// <param name="loaded">Whether the save has been loaded. This is always true.</param> internal static void InvokeLoadedGame(IMonitor monitor, EventArgsLoadedGameChanged loaded) { - if (PlayerEvents.LoadedGame == null) - return; - - string name = $"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}"; - Delegate[] handlers = PlayerEvents.LoadedGame.GetInvocationList(); - - PlayerEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); - monitor.SafelyRaiseGenericEvent(name, handlers, null, loaded); + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}", PlayerEvents._LoadedGame?.GetInvocationList(), null, loaded); } /// <summary>Raise a <see cref="FarmerChanged"/> event.</summary> @@ -66,14 +85,7 @@ namespace StardewModdingAPI.Events /// <param name="newFarmer">The new player character.</param> internal static void InvokeFarmerChanged(IMonitor monitor, SFarmer priorFarmer, SFarmer newFarmer) { - if (PlayerEvents.FarmerChanged == null) - return; - - string name = $"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}"; - Delegate[] handlers = PlayerEvents.FarmerChanged.GetInvocationList(); - - PlayerEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); - monitor.SafelyRaiseGenericEvent(name, handlers, null, new EventArgsFarmerChanged(priorFarmer, newFarmer)); + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}", PlayerEvents._FarmerChanged?.GetInvocationList(), null, new EventArgsFarmerChanged(priorFarmer, newFarmer)); } /// <summary>Raise an <see cref="InventoryChanged"/> event.</summary> diff --git a/src/StardewModdingAPI/Events/TimeEvents.cs b/src/StardewModdingAPI/Events/TimeEvents.cs index 3f06a46b..5dadf567 100644 --- a/src/StardewModdingAPI/Events/TimeEvents.cs +++ b/src/StardewModdingAPI/Events/TimeEvents.cs @@ -1,6 +1,8 @@ using System; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Framework; +#pragma warning disable 618 // Suppress obsolete-symbol errors in this file. Since several events are marked obsolete, this produces unnecessary warnings. namespace StardewModdingAPI.Events { /// <summary>Events raised when the in-game date or time changes.</summary> @@ -12,6 +14,21 @@ namespace StardewModdingAPI.Events /// <summary>Manages deprecation warnings.</summary> private static DeprecationManager DeprecationManager; + /// <summary>The backing field for <see cref="OnNewDay"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsNewDay> _OnNewDay; + + /// <summary>The backing field for <see cref="DayOfMonthChanged"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsIntChanged> _DayOfMonthChanged; + + /// <summary>The backing field for <see cref="SeasonOfYearChanged"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsStringChanged> _SeasonOfYearChanged; + + /// <summary>The backing field for <see cref="YearOfGameChanged"/>.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming")] + private static event EventHandler<EventArgsIntChanged> _YearOfGameChanged; /********* ** Events @@ -23,17 +40,52 @@ namespace StardewModdingAPI.Events public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged; /// <summary>Raised after the day-of-month value changes, including when loading a save. This may happen before save; in most cases you should use <see cref="AfterDayStarted"/> instead.</summary> - public static event EventHandler<EventArgsIntChanged> DayOfMonthChanged; + [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(TimeEvents.AfterDayStarted) + " or " + nameof(SaveEvents) + " instead")] + public static event EventHandler<EventArgsIntChanged> DayOfMonthChanged + { + add + { + TimeEvents.DeprecationManager.Warn($"{nameof(TimeEvents)}.{nameof(TimeEvents.DayOfMonthChanged)}", "1.14", DeprecationLevel.Info); + TimeEvents._DayOfMonthChanged += value; + } + remove => TimeEvents._DayOfMonthChanged -= value; + } /// <summary>Raised after the year value changes.</summary> - public static event EventHandler<EventArgsIntChanged> YearOfGameChanged; + [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(TimeEvents.AfterDayStarted) + " or " + nameof(SaveEvents) + " instead")] + public static event EventHandler<EventArgsIntChanged> YearOfGameChanged + { + add + { + TimeEvents.DeprecationManager.Warn($"{nameof(TimeEvents)}.{nameof(TimeEvents.YearOfGameChanged)}", "1.14", DeprecationLevel.Info); + TimeEvents._YearOfGameChanged += value; + } + remove => TimeEvents._YearOfGameChanged -= value; + } /// <summary>Raised after the season value changes.</summary> - public static event EventHandler<EventArgsStringChanged> SeasonOfYearChanged; + [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(TimeEvents.AfterDayStarted) + " or " + nameof(SaveEvents) + " instead")] + public static event EventHandler<EventArgsStringChanged> SeasonOfYearChanged + { + add + { + TimeEvents.DeprecationManager.Warn($"{nameof(TimeEvents)}.{nameof(TimeEvents.SeasonOfYearChanged)}", "1.14", DeprecationLevel.Info); + TimeEvents._SeasonOfYearChanged += value; + } + remove => TimeEvents._SeasonOfYearChanged -= value; + } /// <summary>Raised when the player is transitioning to a new day and the game is performing its day update logic. This event is triggered twice: once after the game starts transitioning, and again after it finishes.</summary> [Obsolete("Use " + nameof(TimeEvents) + "." + nameof(TimeEvents.AfterDayStarted) + " or " + nameof(SaveEvents) + " instead")] - public static event EventHandler<EventArgsNewDay> OnNewDay; + public static event EventHandler<EventArgsNewDay> OnNewDay + { + add + { + TimeEvents.DeprecationManager.Warn($"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}", "1.6", DeprecationLevel.Info); + TimeEvents._OnNewDay += value; + } + remove => TimeEvents._OnNewDay -= value; + } /********* @@ -68,7 +120,7 @@ namespace StardewModdingAPI.Events /// <param name="newDay">The current day value.</param> internal static void InvokeDayOfMonthChanged(IMonitor monitor, int priorDay, int newDay) { - monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.DayOfMonthChanged)}", TimeEvents.DayOfMonthChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorDay, newDay)); + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.DayOfMonthChanged)}", TimeEvents._DayOfMonthChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorDay, newDay)); } /// <summary>Raise a <see cref="YearOfGameChanged"/> event.</summary> @@ -77,7 +129,7 @@ namespace StardewModdingAPI.Events /// <param name="newYear">The current year value.</param> internal static void InvokeYearOfGameChanged(IMonitor monitor, int priorYear, int newYear) { - monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.YearOfGameChanged)}", TimeEvents.YearOfGameChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorYear, newYear)); + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.YearOfGameChanged)}", TimeEvents._YearOfGameChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorYear, newYear)); } /// <summary>Raise a <see cref="SeasonOfYearChanged"/> event.</summary> @@ -86,7 +138,7 @@ namespace StardewModdingAPI.Events /// <param name="newSeason">The current season name.</param> internal static void InvokeSeasonOfYearChanged(IMonitor monitor, string priorSeason, string newSeason) { - monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.SeasonOfYearChanged)}", TimeEvents.SeasonOfYearChanged?.GetInvocationList(), null, new EventArgsStringChanged(priorSeason, newSeason)); + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.SeasonOfYearChanged)}", TimeEvents._SeasonOfYearChanged?.GetInvocationList(), null, new EventArgsStringChanged(priorSeason, newSeason)); } /// <summary>Raise a <see cref="OnNewDay"/> event.</summary> @@ -96,14 +148,7 @@ namespace StardewModdingAPI.Events /// <param name="isTransitioning">Whether the game just started the transition (<c>true</c>) or finished it (<c>false</c>).</param> internal static void InvokeOnNewDay(IMonitor monitor, int priorDay, int newDay, bool isTransitioning) { - if (TimeEvents.OnNewDay == null) - return; - - string name = $"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}"; - Delegate[] handlers = TimeEvents.OnNewDay.GetInvocationList(); - - TimeEvents.DeprecationManager.WarnForEvent(handlers, name, "1.6", DeprecationLevel.Info); - monitor.SafelyRaiseGenericEvent(name, handlers, null, new EventArgsNewDay(priorDay, newDay, isTransitioning)); + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}", TimeEvents._OnNewDay?.GetInvocationList(), null, new EventArgsNewDay(priorDay, newDay, isTransitioning)); } } } diff --git a/src/StardewModdingAPI/Framework/CommandHelper.cs b/src/StardewModdingAPI/Framework/CommandHelper.cs index 2e9dea8e..86734fc5 100644 --- a/src/StardewModdingAPI/Framework/CommandHelper.cs +++ b/src/StardewModdingAPI/Framework/CommandHelper.cs @@ -50,4 +50,4 @@ namespace StardewModdingAPI.Framework return this.CommandManager.Trigger(name, arguments); } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/Framework/ContentHelper.cs b/src/StardewModdingAPI/Framework/ContentHelper.cs index 893fa2c8..7fd5e803 100644 --- a/src/StardewModdingAPI/Framework/ContentHelper.cs +++ b/src/StardewModdingAPI/Framework/ContentHelper.cs @@ -5,7 +5,11 @@ using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Exceptions; using StardewValley; +using xTile; +using xTile.Format; +using xTile.Tiles; namespace StardewModdingAPI.Framework { @@ -51,6 +55,8 @@ namespace StardewModdingAPI.Framework /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) { + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); + this.AssertValidAssetKeyFormat(key); try { @@ -63,25 +69,49 @@ namespace StardewModdingAPI.Framework // get file FileInfo file = this.GetModFile(key); if (!file.Exists) - throw new ContentLoadException($"There is no file at path '{file.FullName}'."); + throw GetContentError($"there's no matching file at path '{file.FullName}'."); // get asset path string assetPath = this.GetModAssetPath(key, file.FullName); + // try cache + if (this.ContentManager.IsLoaded(assetPath)) + return this.ContentManager.Load<T>(assetPath); + // load content switch (file.Extension.ToLower()) { + // XNB file case ".xnb": - return this.ContentManager.Load<T>(assetPath); + { + T asset = this.ContentManager.Load<T>(assetPath); + if (asset is Map) + this.FixLocalMapTilesheets(asset as Map, key); + return asset; + } + + // unpacked map + case ".tbin": + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.FixLocalMapTilesheets(map, key); + + // inject map + this.ContentManager.Inject(assetPath, map); + return (T)(object)map; + } + // unpacked image case ".png": // validate if (typeof(T) != typeof(Texture2D)) - throw new ContentLoadException($"Can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // try cache - if (this.ContentManager.IsLoaded(assetPath)) - return this.ContentManager.Load<T>(assetPath); + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); // fetch & cache using (FileStream stream = File.OpenRead(file.FullName)) @@ -93,16 +123,16 @@ namespace StardewModdingAPI.Framework } default: - throw new ContentLoadException($"Unknown file extension '{file.Extension}'; must be '.xnb' or '.png'."); + throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); } default: - throw new NotSupportedException($"Unknown content source '{source}'."); + throw GetContentError($"unknown content source '{source}'."); } } - catch (Exception ex) + catch (Exception ex) when (!(ex is SContentLoadException)) { - throw new ContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); } } @@ -130,6 +160,55 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ + /// <summary>Fix the tilesheets for a map loaded from the mod folder.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="mapKey">The map asset key within the mod folder.</param> + /// <exception cref="ContentLoadException">The map tilesheets could not be loaded.</exception> + private void FixLocalMapTilesheets(Map map, string mapKey) + { + if (!map.TileSheets.Any()) + return; + + string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder + foreach (TileSheet tilesheet in map.TileSheets) + { + // check for tilesheet relative to map + { + string localKey = Path.Combine(relativeMapFolder, tilesheet.ImageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + { + try + { + this.Load<Texture2D>(localKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The local '{tilesheet.ImageSource}' tilesheet couldn't be loaded.", ex); + } + tilesheet.ImageSource = this.GetActualAssetKey(localKey); + continue; + } + } + + // fallback to game content + { + string contentKey = tilesheet.ImageSource; + if (contentKey.EndsWith(".png")) + contentKey = contentKey.Substring(0, contentKey.Length - 4); + try + { + this.ContentManager.Load<Texture2D>(contentKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{tilesheet.ImageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + tilesheet.ImageSource = contentKey; + } + } + } + /// <summary>Assert that the given key has a valid format.</summary> /// <param name="key">The asset key to check.</param> /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception> @@ -146,10 +225,18 @@ namespace StardewModdingAPI.Framework /// <param name="path">The asset path relative to the mod folder.</param> private FileInfo GetModFile(string path) { + // try exact match path = Path.Combine(this.ModFolderPath, this.ContentManager.NormalisePathSeparators(path)); FileInfo file = new FileInfo(path); - if (!file.Exists && file.Extension == "") - file = new FileInfo(Path.Combine(this.ModFolderPath, path + ".xnb")); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(path + ".xnb"); + if (result.Exists) + file = result; + } + return file; } diff --git a/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs b/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs new file mode 100644 index 00000000..85d85e3d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs @@ -0,0 +1,18 @@ +using System; +using Microsoft.Xna.Framework.Content; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>An implementation of <see cref="ContentLoadException"/> used by SMAPI to detect whether it was thrown by SMAPI or the underlying framework.</summary> + internal class SContentLoadException : ContentLoadException + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public SContentLoadException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/InternalExtensions.cs b/src/StardewModdingAPI/Framework/InternalExtensions.cs index cadf6598..b99d3798 100644 --- a/src/StardewModdingAPI/Framework/InternalExtensions.cs +++ b/src/StardewModdingAPI/Framework/InternalExtensions.cs @@ -10,23 +10,6 @@ namespace StardewModdingAPI.Framework /// <summary>Provides extension methods for SMAPI's internal use.</summary> internal static class InternalExtensions { - /********* - ** Properties - *********/ - /// <summary>Tracks the installed mods.</summary> - private static ModRegistry ModRegistry; - - - /********* - ** Public methods - *********/ - /// <summary>Injects types required for backwards compatibility.</summary> - /// <param name="modRegistry">Tracks the installed mods.</param> - internal static void Shim(ModRegistry modRegistry) - { - InternalExtensions.ModRegistry = modRegistry; - } - /**** ** IMonitor ****/ @@ -111,27 +94,6 @@ namespace StardewModdingAPI.Framework } /**** - ** Deprecation - ****/ - /// <summary>Log a deprecation warning for mods using an event.</summary> - /// <param name="deprecationManager">The deprecation manager to extend.</param> - /// <param name="handlers">The event handlers.</param> - /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> - /// <param name="version">The SMAPI version which deprecated it.</param> - /// <param name="severity">How deprecated the code is.</param> - public static void WarnForEvent(this DeprecationManager deprecationManager, Delegate[] handlers, string nounPhrase, string version, DeprecationLevel severity) - { - if (handlers == null || !handlers.Any()) - return; - - foreach (Delegate handler in handlers) - { - string modName = InternalExtensions.ModRegistry.GetModFrom(handler) ?? "an unknown mod"; // suppress stack trace for unknown mods, not helpful here - deprecationManager.Warn(modName, nounPhrase, version, severity); - } - } - - /**** ** Sprite batch ****/ /// <summary>Get whether the sprite batch is between a begin and end pair.</summary> diff --git a/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs index 1f6ade1d..8cfe0527 100644 --- a/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs +++ b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs @@ -14,14 +14,23 @@ namespace StardewModdingAPI.Framework.Logging /********* + ** Accessors + *********/ + /// <summary>The full path to the log file being written.</summary> + public string Path { get; } + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="path">The log file to write.</param> public LogFileManager(string path) { + this.Path = path; + // create log directory if needed - string logDir = Path.GetDirectoryName(path); + string logDir = System.IO.Path.GetDirectoryName(path); if (logDir == null) throw new ArgumentException($"The log path '{path}' is not valid."); Directory.CreateDirectory(logDir); diff --git a/src/StardewModdingAPI/Framework/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs index f939b83c..5a8ce459 100644 --- a/src/StardewModdingAPI/Framework/ModHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelper.cs @@ -32,13 +32,15 @@ namespace StardewModdingAPI.Framework /// <summary>An API for managing console commands.</summary> public ICommandHelper ConsoleCommands { get; } + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public ITranslationHelper Translation { get; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="displayName">The mod's display name.</param> - /// <param name="manifest">The manifest for the associated mod.</param> /// <param name="modDirectory">The full path to the mod's folder.</param> /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> /// <param name="modRegistry">Metadata about loaded mods.</param> @@ -47,7 +49,7 @@ namespace StardewModdingAPI.Framework /// <param name="reflection">Simplifies access to private game code.</param> /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> - public ModHelper(string displayName, IManifest manifest, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) + public ModHelper(string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) { // validate if (string.IsNullOrWhiteSpace(modDirectory)) @@ -66,6 +68,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry = modRegistry; this.ConsoleCommands = new CommandHelper(displayName, commandManager); this.Reflection = reflection; + this.Translation = new TranslationHelper(displayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); } /**** @@ -115,6 +118,7 @@ namespace StardewModdingAPI.Framework this.JsonHelper.WriteJsonFile(path, model); } + /**** ** Disposal ****/ diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs index 2c68a639..f5139ce5 100644 --- a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -126,7 +126,6 @@ namespace StardewModdingAPI.Framework.ModLoading } } -#if EXPERIMENTAL /// <summary>Sort the given mods by the order they should be loaded.</summary> /// <param name="mods">The mods to process.</param> public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods) @@ -142,20 +141,18 @@ namespace StardewModdingAPI.Framework.ModLoading states[mod] = ModDependencyStatus.Failed; sortedMods.Push(mod); } - + // sort mods foreach (IModMetadata mod in mods) this.ProcessDependencies(mods.ToArray(), mod, states, sortedMods, new List<IModMetadata>()); return sortedMods.Reverse(); } -#endif /********* ** Private methods *********/ -#if EXPERIMENTAL /// <summary>Sort a mod's dependencies by the order they should be loaded, and remove any mods that can't be loaded due to missing or conflicting dependencies.</summary> /// <param name="mods">The full list of mods being validated.</param> /// <param name="mod">The mod whose dependencies to process.</param> @@ -201,7 +198,7 @@ namespace StardewModdingAPI.Framework.ModLoading string[] missingModIDs = ( from dependency in mod.Manifest.Dependencies - where mods.All(m => m.Manifest.UniqueID != dependency.UniqueID) + where mods.All(m => m.Manifest?.UniqueID != dependency.UniqueID) orderby dependency.UniqueID select dependency.UniqueID ) @@ -222,7 +219,7 @@ namespace StardewModdingAPI.Framework.ModLoading IModMetadata[] modsToLoadFirst = ( from other in mods - where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest.UniqueID) + where mod.Manifest.Dependencies.Any(required => required.UniqueID == other.Manifest?.UniqueID) select other ) .ToArray(); @@ -270,7 +267,6 @@ namespace StardewModdingAPI.Framework.ModLoading return states[mod] = ModDependencyStatus.Sorted; } } -#endif /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary> /// <param name="rootPath">The root folder path to search.</param> diff --git a/src/StardewModdingAPI/Framework/ModRegistry.cs b/src/StardewModdingAPI/Framework/ModRegistry.cs index 62063fbd..f9d3cfbf 100644 --- a/src/StardewModdingAPI/Framework/ModRegistry.cs +++ b/src/StardewModdingAPI/Framework/ModRegistry.cs @@ -63,16 +63,6 @@ namespace StardewModdingAPI.Framework return (from mod in this.Mods select mod); } - /// <summary>Get the friendly mod name which handles a delegate.</summary> - /// <param name="delegate">The delegate to follow.</param> - /// <returns>Returns the mod name, or <c>null</c> if the delegate isn't implemented by a known mod.</returns> - public string GetModFrom(Delegate @delegate) - { - return @delegate?.Target != null - ? this.GetModFrom(@delegate.Target.GetType()) - : null; - } - /// <summary>Get the friendly mod name which defines a type.</summary> /// <param name="type">The type to check.</param> /// <returns>Returns the mod name, or <c>null</c> if the type isn't part of a known mod.</returns> diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs index 53384852..be781585 100644 --- a/src/StardewModdingAPI/Framework/Models/Manifest.cs +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -30,11 +30,9 @@ namespace StardewModdingAPI.Framework.Models /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> public string EntryDll { get; set; } -#if EXPERIMENTAL /// <summary>The other mods that must be loaded before this mod.</summary> [JsonConverter(typeof(ManifestFieldConverter))] public IManifestDependency[] Dependencies { get; set; } -#endif /// <summary>The unique mod ID.</summary> public string UniqueID { get; set; } diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index 54349a91..acd3e108 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -145,6 +145,12 @@ namespace StardewModdingAPI.Framework this.Cache[assetName] = value; } + /// <summary>Get the current content locale.</summary> + public string GetLocale() + { + return this.GetKeyLocale.Invoke<string>(); + } + /********* ** Private methods *********/ diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs index 3d421a37..602a522b 100644 --- a/src/StardewModdingAPI/Framework/SGame.cs +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -55,37 +55,16 @@ namespace StardewModdingAPI.Framework ** Game state ****/ /// <summary>Arrays of pressed controller buttons indexed by <see cref="PlayerIndex"/>.</summary> - private Buttons[] PreviouslyPressedButtons = new Buttons[0]; - - /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the latest tick.</summary> - private KeyboardState KStateNow; + private Buttons[] PreviousPressedButtons = new Buttons[0]; /// <summary>A record of the keyboard state (i.e. the up/down state for each button) as of the previous tick.</summary> - private KeyboardState KStatePrior; - - /// <summary>A record of the mouse state (i.e. the cursor position, scroll amount, and the up/down state for each button) as of the latest tick.</summary> - private MouseState MStateNow; + private KeyboardState PreviousKeyState; /// <summary>A record of the mouse state (i.e. the cursor position, scroll amount, and the up/down state for each button) as of the previous tick.</summary> - private MouseState MStatePrior; - - /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> - private Point MPositionNow; + private MouseState PreviousMouseState; /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> - private Point MPositionPrior; - - /// <summary>The keys that were pressed as of the latest tick.</summary> - private Keys[] CurrentlyPressedKeys => this.KStateNow.GetPressedKeys(); - - /// <summary>The keys that were pressed as of the previous tick.</summary> - private Keys[] PreviouslyPressedKeys => this.KStatePrior.GetPressedKeys(); - - /// <summary>The keys that just entered the down state.</summary> - private Keys[] FramePressedKeys => this.CurrentlyPressedKeys.Except(this.PreviouslyPressedKeys).ToArray(); - - /// <summary>The keys that just entered the up state.</summary> - private Keys[] FrameReleasedKeys => this.PreviouslyPressedKeys.Except(this.CurrentlyPressedKeys).ToArray(); + private Point PreviousMousePosition; /// <summary>The previous save ID at last check.</summary> private ulong PreviousSaveID; @@ -350,20 +329,27 @@ namespace StardewModdingAPI.Framework } /********* - ** Input events + ** Input events (if window has focus) *********/ + if (Game1.game1.IsActive) { // get latest state - this.KStateNow = Keyboard.GetState(); - this.MStateNow = Mouse.GetState(); - this.MPositionNow = new Point(Game1.getMouseX(), Game1.getMouseY()); + KeyboardState keyState = Keyboard.GetState(); + MouseState mouseState = Mouse.GetState(); + Point mousePosition = new Point(Game1.getMouseX(), Game1.getMouseY()); + + // analyse state + Keys[] currentlyPressedKeys = keyState.GetPressedKeys(); + Keys[] previousPressedKeys = this.PreviousKeyState.GetPressedKeys(); + Keys[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray(); + Keys[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray(); // raise key pressed - foreach (Keys key in this.FramePressedKeys) + foreach (Keys key in framePressedKeys) ControlEvents.InvokeKeyPressed(this.Monitor, key); // raise key released - foreach (Keys key in this.FrameReleasedKeys) + foreach (Keys key in frameReleasedKeys) ControlEvents.InvokeKeyReleased(this.Monitor, key); // raise controller button pressed @@ -391,16 +377,18 @@ namespace StardewModdingAPI.Framework } // raise keyboard state changed - if (this.KStateNow != this.KStatePrior) - ControlEvents.InvokeKeyboardChanged(this.Monitor, this.KStatePrior, this.KStateNow); + if (keyState != this.PreviousKeyState) + ControlEvents.InvokeKeyboardChanged(this.Monitor, this.PreviousKeyState, keyState); // raise mouse state changed - if (this.MStateNow != this.MStatePrior) - { - ControlEvents.InvokeMouseChanged(this.Monitor, this.MStatePrior, this.MStateNow, this.MPositionPrior, this.MPositionNow); - this.MStatePrior = this.MStateNow; - this.MPositionPrior = this.MPositionNow; - } + if (mouseState != this.PreviousMouseState) + ControlEvents.InvokeMouseChanged(this.Monitor, this.PreviousMouseState, mouseState, this.PreviousMousePosition, mousePosition); + + // track state + this.PreviousMouseState = mouseState; + this.PreviousMousePosition = mousePosition; + this.PreviousKeyState = keyState; + this.PreviousPressedButtons = this.GetButtonsDown(); } /********* @@ -561,12 +549,6 @@ namespace StardewModdingAPI.Framework if (this.CurrentUpdateTick >= 60) this.CurrentUpdateTick = 0; - /********* - ** Update input state - *********/ - this.KStatePrior = this.KStateNow; - this.PreviouslyPressedButtons = this.GetButtonsDown(); - this.UpdateCrashTimer.Reset(); } catch (Exception ex) @@ -602,13 +584,6 @@ namespace StardewModdingAPI.Framework return; } - // abort in known unrecoverable cases - if (Game1.toolSpriteSheet?.IsDisposed == true) - { - this.Monitor.ExitGameImmediately("the game unexpectedly disposed the tool spritesheet, so it crashed trying to draw a tool. This is a known bug in Stardew Valley 1.2.29, and there's no way to recover from it."); - return; - } - // recover sprite batch try { @@ -1384,7 +1359,7 @@ namespace StardewModdingAPI.Framework /// <param name="buttonState">The last known state.</param> private bool WasButtonJustPressed(Buttons button, ButtonState buttonState) { - return buttonState == ButtonState.Pressed && !this.PreviouslyPressedButtons.Contains(button); + return buttonState == ButtonState.Pressed && !this.PreviousPressedButtons.Contains(button); } /// <summary>Get whether a controller button was released since the last check.</summary> @@ -1392,7 +1367,7 @@ namespace StardewModdingAPI.Framework /// <param name="buttonState">The last known state.</param> private bool WasButtonJustReleased(Buttons button, ButtonState buttonState) { - return buttonState == ButtonState.Released && this.PreviouslyPressedButtons.Contains(button); + return buttonState == ButtonState.Released && this.PreviousPressedButtons.Contains(button); } /// <summary>Get whether an analogue controller button was pressed since the last check.</summary> diff --git a/src/StardewModdingAPI/Framework/TranslationHelper.cs b/src/StardewModdingAPI/Framework/TranslationHelper.cs new file mode 100644 index 00000000..1e73c425 --- /dev/null +++ b/src/StardewModdingAPI/Framework/TranslationHelper.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class TranslationHelper : ITranslationHelper + { + /********* + ** Properties + *********/ + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The translations for each locale.</summary> + private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> + private IDictionary<string, string> ForLocale; + + + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + public string Locale { get; private set; } + + /// <summary>The game's current language code.</summary> + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The initial locale.</param> + /// <param name="languageCode">The game's current language code.</param> + public TranslationHelper(string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + { + // save data + this.ModName = modName; + + // set locale + this.SetLocale(locale, languageCode); + } + + /// <summary>Get all translations for the current locale.</summary> + public IDictionary<string, string> GetTranslations() + { + return new Dictionary<string, string>(this.ForLocale, StringComparer.InvariantCultureIgnoreCase); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out string text); + return new Translation(this.ModName, this.Locale, key, text); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// <summary>Set the translations to use.</summary> + /// <param name="translations">The translations to use.</param> + internal TranslationHelper SetTranslations(IDictionary<string, IDictionary<string, string>> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + /// <summary>Set the current locale and precache translations.</summary> + /// <param name="locale">The current locale.</param> + /// <param name="localeEnum">The game's current language code.</param> + internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair); + } + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> + /// <param name="locale">The locale for which to find valid locales.</param> + private IEnumerable<string> GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs index c036fdd3..9533aadb 100644 --- a/src/StardewModdingAPI/IManifest.cs +++ b/src/StardewModdingAPI/IManifest.cs @@ -29,10 +29,8 @@ namespace StardewModdingAPI /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> string EntryDll { get; } -#if EXPERIMENTAL /// <summary>The other mods that must be loaded before this mod.</summary> IManifestDependency[] Dependencies { get; } -#endif /// <summary>Any manifest fields which didn't match a valid field.</summary> IDictionary<string, object> ExtraFields { get; } diff --git a/src/StardewModdingAPI/IModHelper.cs b/src/StardewModdingAPI/IModHelper.cs index cdff6ac8..116e8508 100644 --- a/src/StardewModdingAPI/IModHelper.cs +++ b/src/StardewModdingAPI/IModHelper.cs @@ -21,6 +21,9 @@ /// <summary>An API for managing console commands.</summary> ICommandHelper ConsoleCommands { get; } + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + ITranslationHelper Translation { get; } + /********* ** Public methods diff --git a/src/StardewModdingAPI/ITranslationHelper.cs b/src/StardewModdingAPI/ITranslationHelper.cs new file mode 100644 index 00000000..15f6b3c8 --- /dev/null +++ b/src/StardewModdingAPI/ITranslationHelper.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public interface ITranslationHelper + { + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + string Locale { get; } + + /// <summary>The game's current language code.</summary> + LocalizedContentManager.LanguageCode LocaleEnum { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get all translations for the current locale.</summary> + IDictionary<string, string> GetTranslations(); + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + Translation Get(string key); + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + Translation Get(string key, object tokens); + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index 06523144..d75d5193 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -48,6 +48,9 @@ namespace StardewModdingAPI /// <summary>The underlying game instance.</summary> private SGame GameInstance; + /// <summary>The underlying content manager.</summary> + private SContentManager ContentManager => (SContentManager)this.GameInstance.Content; + /// <summary>The SMAPI configuration settings.</summary> /// <remarks>This is initialised after the game starts.</remarks> private SConfig Settings; @@ -78,6 +81,8 @@ namespace StardewModdingAPI /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { + Program.AssertMinimumCompatibility(); + // get flags from arguments bool writeToConsole = !args.Contains("--no-terminal"); @@ -158,7 +163,7 @@ namespace StardewModdingAPI try { File.WriteAllText(Constants.FatalCrashMarker, string.Empty); - File.Copy(Constants.DefaultLogPath, Constants.FatalCrashLog, overwrite: true); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); } catch (Exception ex) { @@ -177,6 +182,7 @@ namespace StardewModdingAPI this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); GameEvents.GameLoadedInternal += (sender, e) => this.CheckForUpdateAsync(); + ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} - running SMAPI {Constants.ApiVersion}"; @@ -261,6 +267,48 @@ namespace StardewModdingAPI /********* ** Private methods *********/ + /// <summary>Assert that the minimum conditions are present to initialise SMAPI without type load exceptions.</summary> + private static void AssertMinimumCompatibility() + { + void PrintErrorAndExit(string message) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + Program.PressAnyKeyToExit(showMessage: true); + } + + // get game assembly name + const string gameAssemblyName = +#if SMAPI_FOR_WINDOWS + "Stardew Valley"; +#else + "StardewValley"; +#endif + + // game not present + if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit( + "Oops! SMAPI can't find the game. " + + (Assembly.GetCallingAssembly().Location?.Contains(Path.Combine("internal", "Windows")) == true || Assembly.GetCallingAssembly().Location?.Contains(Path.Combine("internal", "Mono")) == true + ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " + : "Make sure you're running StardewModdingAPI.exe in your game folder. " + ) + + "See the readme.txt file for details." + ); + } + + // Stardew Valley 1.2 types not present + if (Type.GetType($"StardewValley.LocalizedContentManager+LanguageCode, {gameAssemblyName}", throwOnError: false) == null) + { + PrintErrorAndExit(Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion) + ? $"Oops! You're running Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)}, but the oldest supported version is {Constants.GetGameDisplayVersion(Constants.MinimumGameVersion)}. Please update your game before using SMAPI." + : "Oops! SMAPI doesn't seem to be compatible with your game. Make sure you're running the latest version of Stardew Valley and SMAPI." + ); + } + } + /// <summary>Initialise SMAPI and mods after the game starts.</summary> private void InitialiseAfterGameStart() { @@ -277,7 +325,6 @@ namespace StardewModdingAPI #pragma warning disable 618 Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); Config.Shim(this.DeprecationManager); - InternalExtensions.Shim(this.ModRegistry); Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); Mod.Shim(this.DeprecationManager); ContentEvents.Shim(this.ModRegistry, this.Monitor); @@ -357,13 +404,11 @@ namespace StardewModdingAPI } } -#if EXPERIMENTAL // process dependencies mods = resolver.ProcessDependencies(mods).ToArray(); -#endif // load mods - modsLoaded = this.LoadMods(mods, new JsonHelper(), (SContentManager)Game1.content, deprecationWarnings); + modsLoaded = this.LoadMods(mods, new JsonHelper(), this.ContentManager, deprecationWarnings); foreach (Action warning in deprecationWarnings) warning(); } @@ -381,24 +426,42 @@ namespace StardewModdingAPI new Thread(this.RunConsoleLoop).Start(); } + /// <summary>Handle the game changing locale.</summary> + private void OnLocaleChanged() + { + // get locale + string locale = this.ContentManager.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + } + /// <summary>Run a loop handling console input.</summary> [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] private void RunConsoleLoop() { - // prepare help command + // prepare console this.Monitor.Log("Starting console..."); this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.CommandManager.Add("SMAPI", "help", "Lists all commands | 'help <cmd>' returns command description", this.HandleHelpCommand); + this.CommandManager.Add("SMAPI", "help", "Lists command documentation.\n\nUsage: help\nLists all available commands.\n\nUsage: help <cmd>\n- cmd: The name of a command whose documentation to display.", this.HandleCommand); + this.CommandManager.Add("SMAPI", "reload_i18n", "Reloads translation files for all mods.\n\nUsage: reload_i18n", this.HandleCommand); // start handling command line input Thread inputThread = new Thread(() => { while (true) { + // get input string input = Console.ReadLine(); + if (string.IsNullOrWhiteSpace(input)) + continue; + + // parse input try { - if (!string.IsNullOrWhiteSpace(input) && !this.CommandManager.Trigger(input)) + if (!this.CommandManager.Trigger(input)) this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); } catch (Exception ex) @@ -580,14 +643,14 @@ namespace StardewModdingAPI // inject data mod.ModManifest = manifest; - mod.Helper = new ModHelper(metadata.DisplayName, manifest, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, this.Reflection); + mod.Helper = new ModHelper(metadata.DisplayName, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, this.Reflection); mod.Monitor = this.GetSecondaryMonitor(metadata.DisplayName); mod.PathOnDisk = metadata.DirectoryPath; // track mod metadata.SetMod(mod); this.ModRegistry.Add(metadata); - modsLoaded += 1; + modsLoaded++; this.Monitor.Log($"Loaded {metadata.DisplayName} by {manifest.Author}, v{manifest.Version} | {manifest.Description}", LogLevel.Info); } catch (Exception ex) @@ -596,6 +659,9 @@ namespace StardewModdingAPI } } + // initialise translations + this.ReloadTranslations(); + // initialise loaded mods foreach (IModMetadata metadata in this.ModRegistry.GetMods()) { @@ -622,23 +688,67 @@ namespace StardewModdingAPI return modsLoaded; } - /// <summary>The method called when the user submits the help command in the console.</summary> - /// <param name="name">The command name.</param> - /// <param name="arguments">The command arguments.</param> - private void HandleHelpCommand(string name, string[] arguments) + /// <summary>Reload translations for all mods.</summary> + private void ReloadTranslations() { - if (arguments.Any()) + JsonHelper jsonHelper = new JsonHelper(); + foreach (IModMetadata metadata in this.ModRegistry.GetMods()) { - Framework.Command result = this.CommandManager.Get(arguments[0]); - if (result == null) - this.Monitor.Log("There's no command with that name.", LogLevel.Error); - else - this.Monitor.Log($"{result.Name}: {result.Documentation}\n(Added by {result.ModName}.)", LogLevel.Info); + // read translation files + IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); + DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + { + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try + { + translations[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't read {metadata.DisplayName}'s i18n/{locale}.json file: {ex.GetLogSummary()}"); + } + } + } + + // update translation + TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; + translationHelper.SetTranslations(translations); } - else + } + + /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + private void HandleCommand(string name, string[] arguments) + { + switch (name) { - this.Monitor.Log("The following commands are registered: " + string.Join(", ", this.CommandManager.GetAll().Select(p => p.Name)) + ".", LogLevel.Info); - this.Monitor.Log("For more information about a command, type 'help command_name'.", LogLevel.Info); + case "help": + if (arguments.Any()) + { + Framework.Command result = this.CommandManager.Get(arguments[0]); + if (result == null) + this.Monitor.Log("There's no command with that name.", LogLevel.Error); + else + this.Monitor.Log($"{result.Name}: {result.Documentation}\n(Added by {result.ModName}.)", LogLevel.Info); + } + else + { + this.Monitor.Log("The following commands are registered: " + string.Join(", ", this.CommandManager.GetAll().Select(p => p.Name)) + ".", LogLevel.Info); + this.Monitor.Log("For more information about a command, type 'help command_name'.", LogLevel.Info); + } + break; + + case "reload_i18n": + this.ReloadTranslations(); + this.Monitor.Log("Reloaded translation files for all mods. This only affects new translations the mods fetch; if they cached some text, it may not be updated.", LogLevel.Info); + break; + + default: + throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); } } @@ -655,6 +765,15 @@ namespace StardewModdingAPI private void PressAnyKeyToExit() { this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); + Program.PressAnyKeyToExit(showMessage: false); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + /// <param name="showMessage">Whether to print a 'press any key to exit' message to the console.</param> + private static void PressAnyKeyToExit(bool showMessage) + { + if (showMessage) + Console.WriteLine("Game has ended. Press any key to exit."); Thread.Sleep(100); Console.ReadKey(); Environment.Exit(0); diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json index 08bd3cff..f62db90c 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.config.json +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -52,11 +52,11 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha { "Name": "Better Sprinklers", "ID": [ "SPDSprinklersMod", /*since 2.3*/ "Speeder.BetterSprinklers" ], - "UpperVersion": "2.3", + "UpperVersion": "2.3.1-pathoschild-update", "Compatibility": "AssumeBroken", "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/41", "UnofficialUpdateUrl": "http://community.playstarbound.com/threads/132096", - "Notes": "Needs update for SDV 1.2." + "Notes": "Needs update for SDV 1.2 and to migrate broken TimeEvents.AfterDayOfMonthChanged." }, { "Name": "Birthday Mail", @@ -332,6 +332,14 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "Notes": "Needs update for SDV 1.2." }, { + "Name": "TimeSpeed", + "ID": [ "TimeSpeed.dll", /* since 2.0.3 */ "4108e859-333c-4fec-a1a7-d2e18c1019fe", /*since 2.1*/ "community.TimeSpeed" ], + "UpperVersion": "2.2", + "Compatibility": "AssumeBroken", + "UpdateUrl": "http://www.nexusmods.com/stardewvalley/mods/169", + "Notes": "Needs update for SDV 1.2 and to migrate broken TimeEvents.AfterDayOfMonthChanged." + }, + { "Name": "UiModSuite", "ID": [ "Demiacle.UiModSuite" ], "UpperVersion": "0.5", diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index d8bfd473..ae454a35 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -94,6 +94,7 @@ <Compile Include="ContentSource.cs" /> <Compile Include="Events\ContentEvents.cs" /> <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Framework\Exceptions\SContentLoadException.cs" /> <Compile Include="Framework\Command.cs" /> <Compile Include="Config.cs" /> <Compile Include="Constants.cs" /> @@ -149,6 +150,7 @@ <Compile Include="Framework\Serialisation\JsonHelper.cs" /> <Compile Include="Framework\Serialisation\SelectiveStringEnumConverter.cs" /> <Compile Include="Framework\Serialisation\ManifestFieldConverter.cs" /> + <Compile Include="Framework\TranslationHelper.cs" /> <Compile Include="ICommandHelper.cs" /> <Compile Include="IContentEventData.cs" /> <Compile Include="IContentEventHelper.cs" /> @@ -178,6 +180,7 @@ <Compile Include="Framework\Logging\LogFileManager.cs" /> <Compile Include="IPrivateProperty.cs" /> <Compile Include="ISemanticVersion.cs" /> + <Compile Include="ITranslationHelper.cs" /> <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> <Compile Include="Framework\UpdateHelper.cs" /> @@ -198,6 +201,7 @@ <Compile Include="IPrivateMethod.cs" /> <Compile Include="IReflectionHelper.cs" /> <Compile Include="SemanticVersion.cs" /> + <Compile Include="Translation.cs" /> </ItemGroup> <ItemGroup> <None Include="App.config"> diff --git a/src/StardewModdingAPI/Translation.cs b/src/StardewModdingAPI/Translation.cs new file mode 100644 index 00000000..764145ea --- /dev/null +++ b/src/StardewModdingAPI/Translation.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace StardewModdingAPI +{ + /// <summary>A translation string with a fluent API to customise it.</summary> + public class Translation + { + /********* + ** Properties + *********/ + /// <summary>The placeholder text when the translation is <c>null</c> or empty, where <c>{0}</c> is the translation key.</summary> + internal const string PlaceholderText = "(no translation:{0})"; + + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The locale for which the translation was fetched.</summary> + private readonly string Locale; + + /// <summary>The translation key.</summary> + private readonly string Key; + + /// <summary>The underlying translation text.</summary> + private readonly string Text; + + /// <summary>The value to return if the translations is undefined.</summary> + private readonly string Placeholder; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + internal Translation(string modName, string locale, string key, string text) + : this(modName, locale, key, text, string.Format(Translation.PlaceholderText, key)) { } + + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + /// <param name="placeholder">The value to return if the translations is undefined.</param> + internal Translation(string modName, string locale, string key, string text, string placeholder) + { + this.ModName = modName; + this.Locale = locale; + this.Key = key; + this.Text = text; + this.Placeholder = placeholder; + } + + /// <summary>Throw an exception if the translation text is <c>null</c> or empty.</summary> + /// <exception cref="KeyNotFoundException">There's no available translation matching the requested key and locale.</exception> + public Translation Assert() + { + if (!this.HasValue()) + throw new KeyNotFoundException($"The '{this.ModName}' mod doesn't have a translation with key '{this.Key}' for the '{this.Locale}' locale or its fallbacks."); + return this; + } + + /// <summary>Replace the text if it's <c>null</c> or empty. If you set a <c>null</c> or empty value, the translation will show the fallback "no translation" placeholder (see <see cref="UsePlaceholder"/> if you want to disable that). Returns a new instance if changed.</summary> + /// <param name="default">The default value.</param> + public Translation Default(string @default) + { + return this.HasValue() + ? this + : new Translation(this.ModName, this.Locale, this.Key, @default); + } + + /// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty. Returns a new instance.</summary> + /// <param name="use">Whether to return a placeholder.</param> + public Translation UsePlaceholder(bool use) + { + return new Translation(this.ModName, this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, this.Key) : null); + } + + /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + /// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception> + public Translation Tokens(object tokens) + { + if (string.IsNullOrWhiteSpace(this.Text) || tokens == null) + return this; + + // get dictionary of tokens + IDictionary<string, string> tokenLookup = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase); + { + // from dictionary + if (tokens is IDictionary inputLookup) + { + foreach (DictionaryEntry entry in inputLookup) + { + string key = entry.Key?.ToString().Trim(); + if (key != null) + tokenLookup[key] = entry.Value?.ToString(); + } + } + + // from object properties + else + { + Type type = tokens.GetType(); + foreach (PropertyInfo prop in type.GetProperties()) + tokenLookup[prop.Name] = prop.GetValue(tokens)?.ToString(); + foreach (FieldInfo field in type.GetFields()) + tokenLookup[field.Name] = field.GetValue(tokens)?.ToString(); + } + } + + // format translation + string text = Regex.Replace(this.Text, @"{{([ \w\.\-]+)}}", match => + { + string key = match.Groups[1].Value.Trim(); + return tokenLookup.TryGetValue(key, out string value) + ? value + : match.Value; + }); + return new Translation(this.ModName, this.Locale, this.Key, text); + } + + /// <summary>Get whether the translation has a defined value.</summary> + public bool HasValue() + { + return !string.IsNullOrEmpty(this.Text); + } + + /// <summary>Get the translation text. Calling this method isn't strictly necessary, since you can assign a <see cref="Translation"/> value directly to a string.</summary> + public override string ToString() + { + return this.Placeholder != null && !this.HasValue() + ? this.Placeholder + : this.Text; + } + + /// <summary>Get a string representation of the given translation.</summary> + /// <param name="translation">The translation key.</param> + public static implicit operator string(Translation translation) + { + return translation?.ToString(); + } + } +} diff --git a/src/StardewModdingAPI/unix-launcher.sh b/src/StardewModdingAPI/unix-launcher.sh index bf0e9d5e..39fd4f29 100644 --- a/src/StardewModdingAPI/unix-launcher.sh +++ b/src/StardewModdingAPI/unix-launcher.sh @@ -12,64 +12,75 @@ ARCH=`uname -m` # MonoKickstart picks the right libfolder, so just execute the right binary. if [ "$UNAME" == "Darwin" ]; then - # ... Except on OSX. - export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:./osx/ + # ... Except on OSX. + export DYLD_LIBRARY_PATH=$DYLD_LIBRARY_PATH:./osx/ - # El Capitan is a total idiot and wipes this variable out, making the - # Steam overlay disappear. This sidesteps "System Integrity Protection" - # and resets the variable with Valve's own variable (they provided this - # fix by the way, thanks Valve!). Note that you will need to update your - # launch configuration to the script location, NOT just the app location - # (i.e. Kick.app/Contents/MacOS/Kick, not just Kick.app). - # -flibit - if [ "$STEAM_DYLD_INSERT_LIBRARIES" != "" ] && [ "$DYLD_INSERT_LIBRARIES" == "" ]; then - export DYLD_INSERT_LIBRARIES="$STEAM_DYLD_INSERT_LIBRARIES" - fi + # El Capitan is a total idiot and wipes this variable out, making the + # Steam overlay disappear. This sidesteps "System Integrity Protection" + # and resets the variable with Valve's own variable (they provided this + # fix by the way, thanks Valve!). Note that you will need to update your + # launch configuration to the script location, NOT just the app location + # (i.e. Kick.app/Contents/MacOS/Kick, not just Kick.app). + # -flibit + if [ "$STEAM_DYLD_INSERT_LIBRARIES" != "" ] && [ "$DYLD_INSERT_LIBRARIES" == "" ]; then + export DYLD_INSERT_LIBRARIES="$STEAM_DYLD_INSERT_LIBRARIES" + fi - ln -sf mcs.bin.osx mcs - cp StardewValley.bin.osx StardewModdingAPI.bin.osx - open -a Terminal ./StardewModdingAPI.bin.osx $@ + # this was here before + ln -sf mcs.bin.osx mcs + + # fix "DllNotFoundException: libgdiplus.dylib" errors when loading images in SMAPI + if [ -f libgdiplus.dylib ]; then + rm libgdiplus.dylib + fi + if [ -f /Library/Frameworks/Mono.framework/Versions/Current/lib/libgdiplus.dylib ]; then + ln -s /Library/Frameworks/Mono.framework/Versions/Current/lib/libgdiplus.dylib libgdiplus.dylib + fi + + # launch SMAPI + cp StardewValley.bin.osx StardewModdingAPI.bin.osx + open -a Terminal ./StardewModdingAPI.bin.osx $@ else - # choose launcher - LAUNCHER="" - if [ "$ARCH" == "x86_64" ]; then - ln -sf mcs.bin.x86_64 mcs - cp StardewValley.bin.x86_64 StardewModdingAPI.bin.x86_64 - LAUNCHER="./StardewModdingAPI.bin.x86_64 $@" - else - ln -sf mcs.bin.x86 mcs - cp StardewValley.bin.x86 StardewModdingAPI.bin.x86 - LAUNCHER="./StardewModdingAPI.bin.x86 $@" - fi + # choose launcher + LAUNCHER="" + if [ "$ARCH" == "x86_64" ]; then + ln -sf mcs.bin.x86_64 mcs + cp StardewValley.bin.x86_64 StardewModdingAPI.bin.x86_64 + LAUNCHER="./StardewModdingAPI.bin.x86_64 $@" + else + ln -sf mcs.bin.x86 mcs + cp StardewValley.bin.x86 StardewModdingAPI.bin.x86 + LAUNCHER="./StardewModdingAPI.bin.x86 $@" + fi - # get cross-distro version of POSIX command - COMMAND="" - if command -v command 2>/dev/null; then - COMMAND="command -v" - elif type type 2>/dev/null; then - COMMAND="type" - fi + # get cross-distro version of POSIX command + COMMAND="" + if command -v command 2>/dev/null; then + COMMAND="command -v" + elif type type 2>/dev/null; then + COMMAND="type" + fi - # open SMAPI in terminal - if $COMMAND x-terminal-emulator 2>/dev/null; then - x-terminal-emulator -e "$LAUNCHER" - elif $COMMAND gnome-terminal 2>/dev/null; then - gnome-terminal -e "$LAUNCHER" - elif $COMMAND xterm 2>/dev/null; then - xterm -e "$LAUNCHER" - elif $COMMAND konsole 2>/dev/null; then - konsole -e "$LAUNCHER" - elif $COMMAND terminal 2>/dev/null; then - terminal -e "$LAUNCHER" - else - $LAUNCHER - fi + # open SMAPI in terminal + if $COMMAND x-terminal-emulator 2>/dev/null; then + x-terminal-emulator -e "$LAUNCHER" + elif $COMMAND gnome-terminal 2>/dev/null; then + gnome-terminal -e "$LAUNCHER" + elif $COMMAND xterm 2>/dev/null; then + xterm -e "$LAUNCHER" + elif $COMMAND konsole 2>/dev/null; then + konsole -e "$LAUNCHER" + elif $COMMAND terminal 2>/dev/null; then + terminal -e "$LAUNCHER" + else + $LAUNCHER + fi - # some Linux users get error 127 (command not found) from the above block, even though - # `command -v` indicates the command is valid. As a fallback, launch SMAPI without a terminal when - # that happens and pass in an argument indicating SMAPI shouldn't try writing to the terminal - # (which can be slow if there is none). - if [ $? -eq 127 ]; then - $LAUNCHER --no-terminal - fi + # some Linux users get error 127 (command not found) from the above block, even though + # `command -v` indicates the command is valid. As a fallback, launch SMAPI without a terminal when + # that happens and pass in an argument indicating SMAPI shouldn't try writing to the terminal + # (which can be slow if there is none). + if [ $? -eq 127 ]; then + $LAUNCHER --no-terminal + fi fi diff --git a/src/TrainerMod/TrainerMod.cs b/src/TrainerMod/TrainerMod.cs index 95c7cbaf..9a3a8d0b 100644 --- a/src/TrainerMod/TrainerMod.cs +++ b/src/TrainerMod/TrainerMod.cs @@ -124,9 +124,15 @@ namespace TrainerMod switch (command) { case "debug": + // submit command string debugCommand = string.Join(" ", args); - this.Monitor.Log($"Sending debug command to the game: {debugCommand}...", LogLevel.Info); + string oldOutput = Game1.debugOutput; Game1.game1.parseDebugInput(debugCommand); + + // show result + this.Monitor.Log(Game1.debugOutput != oldOutput + ? $"> {Game1.debugOutput}" + : "Sent debug command to the game, but there was no output.", LogLevel.Info); break; case "save": diff --git a/src/TrainerMod/manifest.json b/src/TrainerMod/manifest.json index 8bddf02d..1232a365 100644 --- a/src/TrainerMod/manifest.json +++ b/src/TrainerMod/manifest.json @@ -3,7 +3,7 @@ "Author": "SMAPI", "Version": { "MajorVersion": 1, - "MinorVersion": 9, + "MinorVersion": 14, "PatchVersion": 0, "Build": null }, diff --git a/src/crossplatform.targets b/src/crossplatform.targets index 00b731eb..31d4722d 100644 --- a/src/crossplatform.targets +++ b/src/crossplatform.targets @@ -33,7 +33,6 @@ </Reference> <Reference Include="Stardew Valley"> <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> - <Private>False</Private> </Reference> <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86"> <HintPath>$(GamePath)\xTile.dll</HintPath> diff --git a/src/prepare-install-package.targets b/src/prepare-install-package.targets index 9a514abd..f2a2b23c 100644 --- a/src/prepare-install-package.targets +++ b/src/prepare-install-package.targets @@ -35,9 +35,6 @@ <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Mono" /> <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Mono\Mods\%(RecursiveDir)" /> - <!--copy Mono files needed by SMAPI on Linux/Mac --> - <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(SolutionDir)\..\lib\libgdiplus.dylib" DestinationFolder="$(PackageInternalPath)\Mono" /> - <!-- copy SMAPI files for Windows --> <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Windows" /> <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Windows" /> |