summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/GlobalAssemblyInfo.cs4
-rw-r--r--src/StardewModdingAPI.Installer/InteractiveInstaller.cs6
-rw-r--r--src/StardewModdingAPI.Installer/readme.txt10
-rw-r--r--src/StardewModdingAPI.Tests/ModResolverTests.cs45
-rw-r--r--src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj2
-rw-r--r--src/StardewModdingAPI.Tests/TranslationTests.cs348
-rw-r--r--src/StardewModdingAPI/Constants.cs4
-rw-r--r--src/StardewModdingAPI/Context.cs12
-rw-r--r--src/StardewModdingAPI/Events/ContentEvents.cs37
-rw-r--r--src/StardewModdingAPI/Events/GameEvents.cs98
-rw-r--r--src/StardewModdingAPI/Events/PlayerEvents.cs48
-rw-r--r--src/StardewModdingAPI/Events/TimeEvents.cs75
-rw-r--r--src/StardewModdingAPI/Framework/CommandHelper.cs2
-rw-r--r--src/StardewModdingAPI/Framework/ContentHelper.cs113
-rw-r--r--src/StardewModdingAPI/Framework/Exceptions/SContentLoadException.cs18
-rw-r--r--src/StardewModdingAPI/Framework/InternalExtensions.cs38
-rw-r--r--src/StardewModdingAPI/Framework/Logging/LogFileManager.cs11
-rw-r--r--src/StardewModdingAPI/Framework/ModHelper.cs8
-rw-r--r--src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs10
-rw-r--r--src/StardewModdingAPI/Framework/ModRegistry.cs10
-rw-r--r--src/StardewModdingAPI/Framework/Models/Manifest.cs2
-rw-r--r--src/StardewModdingAPI/Framework/SContentManager.cs6
-rw-r--r--src/StardewModdingAPI/Framework/SGame.cs83
-rw-r--r--src/StardewModdingAPI/Framework/TranslationHelper.cs137
-rw-r--r--src/StardewModdingAPI/IManifest.cs2
-rw-r--r--src/StardewModdingAPI/IModHelper.cs3
-rw-r--r--src/StardewModdingAPI/ITranslationHelper.cs34
-rw-r--r--src/StardewModdingAPI/Program.cs165
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.config.json12
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.csproj4
-rw-r--r--src/StardewModdingAPI/Translation.cs150
-rw-r--r--src/StardewModdingAPI/unix-launcher.sh119
-rw-r--r--src/TrainerMod/TrainerMod.cs8
-rw-r--r--src/TrainerMod/manifest.json2
-rw-r--r--src/crossplatform.targets1
-rw-r--r--src/prepare-install-package.targets3
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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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" />