diff options
Diffstat (limited to 'src')
216 files changed, 19776 insertions, 0 deletions
diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 00000000..a5cdcf97 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,68 @@ +# topmost editorconfig +root: true + +########## +## General formatting +## documentation: http://editorconfig.org +########## +[*] +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.csproj] +indent_size = 2 +insert_final_newline = false + +[*.json] +indent_size = 2 + +########## +## C# formatting +## documentation: https://docs.microsoft.com/en-us/visualstudio/ide/editorconfig-code-style-settings-reference +########## +[*.cs] + +#sort 'system' usings first +dotnet_sort_system_directives_first = true + +# use 'this.' qualifier +dotnet_style_qualification_for_field = true:error +dotnet_style_qualification_for_property = true:error +dotnet_style_qualification_for_method = true:error +dotnet_style_qualification_for_event = true:error + +# use language keywords (like int) instead of type (like Int32) +dotnet_style_predefined_type_for_locals_parameters_members = true:error +dotnet_style_predefined_type_for_member_access = true:error + +# don't use 'var' for language keywords +csharp_style_var_for_built_in_types = false:error + +# suggest modern C# features where simpler +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_simple_default_expression = true:suggestion + +# prefer method block bodies +csharp_style_expression_bodied_methods = false:suggestion +csharp_style_expression_bodied_constructors = false:suggestion + +# prefer property expression bodies +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion + +# prefer inline out variables +csharp_style_inlined_variable_declaration = true:warning + +# avoid superfluous braces +csharp_prefer_braces = false:suggestion diff --git a/src/GlobalAssemblyInfo.cs b/src/GlobalAssemblyInfo.cs new file mode 100644 index 00000000..882e3bda --- /dev/null +++ b/src/GlobalAssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: ComVisible(false)] +[assembly: AssemblyVersion("1.15.4.0")] +[assembly: AssemblyFileVersion("1.15.4.0")] diff --git a/src/StardewModdingAPI.Installer/Enums/Platform.cs b/src/StardewModdingAPI.Installer/Enums/Platform.cs new file mode 100644 index 00000000..9bcaa3c3 --- /dev/null +++ b/src/StardewModdingAPI.Installer/Enums/Platform.cs @@ -0,0 +1,12 @@ +namespace StardewModdingApi.Installer.Enums +{ + /// <summary>The game's platform version.</summary> + internal enum Platform + { + /// <summary>The Linux/Mac version of the game.</summary> + Mono, + + /// <summary>The Windows version of the game.</summary> + Windows + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.Installer/Enums/ScriptAction.cs b/src/StardewModdingAPI.Installer/Enums/ScriptAction.cs new file mode 100644 index 00000000..e62b2a7c --- /dev/null +++ b/src/StardewModdingAPI.Installer/Enums/ScriptAction.cs @@ -0,0 +1,12 @@ +namespace StardewModdingApi.Installer.Enums +{ + /// <summary>The action to perform.</summary> + internal enum ScriptAction + { + /// <summary>Install SMAPI to the game directory.</summary> + Install, + + /// <summary>Remove SMAPI from the game directory.</summary> + Uninstall + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI.Installer/InteractiveInstaller.cs b/src/StardewModdingAPI.Installer/InteractiveInstaller.cs new file mode 100644 index 00000000..b52529a6 --- /dev/null +++ b/src/StardewModdingAPI.Installer/InteractiveInstaller.cs @@ -0,0 +1,746 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading; +using Microsoft.Win32; +using StardewModdingApi.Installer.Enums; + +namespace StardewModdingApi.Installer +{ + /// <summary>Interactively performs the install and uninstall logic.</summary> + internal class InteractiveInstaller + { + /********* + ** Properties + *********/ + /// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary> + private readonly Version Windows7Version = new Version(6, 1); + + /// <summary>The default file paths where Stardew Valley can be installed.</summary> + /// <param name="platform">The target platform.</param> + /// <remarks>Derived from the crossplatform mod config: https://github.com/Pathoschild/Stardew.ModBuildConfig. </remarks> + private IEnumerable<string> GetDefaultInstallPaths(Platform platform) + { + switch (platform) + { + case Platform.Mono: + { + string home = Environment.GetEnvironmentVariable("HOME"); + + // Linux + yield return $"{home}/GOG Games/Stardew Valley/game"; + yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") + ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" + : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; + + // Mac + yield return "/Applications/Stardew Valley.app/Contents/MacOS"; + yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; + } + break; + + case Platform.Windows: + { + // Windows + yield return @"C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley"; + yield return @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley"; + + // Windows registry + IDictionary<string, string> registryKeys = new Dictionary<string, string> + { + [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150"] = "InstallLocation", // Steam + [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows + }; + foreach (var pair in registryKeys) + { + string path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); + if (!string.IsNullOrWhiteSpace(path)) + yield return path; + } + } + break; + + default: + throw new InvalidOperationException($"Unknown platform '{platform}'."); + } + } + + /// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary> + /// <param name="installDir">The folder for Stardew Valley and SMAPI.</param> + /// <param name="modsDir">The folder for SMAPI mods.</param> + private IEnumerable<string> GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) + { + string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); + + // common + yield return GetInstallPath("Mono.Cecil.dll"); + yield return GetInstallPath("Newtonsoft.Json.dll"); + yield return GetInstallPath("StardewModdingAPI.exe"); + yield return GetInstallPath("StardewModdingAPI.config.json"); + yield return GetInstallPath("StardewModdingAPI.data.json"); + yield return GetInstallPath("System.ValueTuple.dll"); + 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"); + yield return GetInstallPath("System.Runtime.Caching.dll"); + + // Windows only + yield return GetInstallPath("StardewModdingAPI.pdb"); + + // obsolete + yield return GetInstallPath("Mods/.cache"); // 1.3-1.4 + yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 + yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0–1.4 + yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3–1.15.4 + if (modsDir.Exists) + { + foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories()) + yield return Path.Combine(modDir.FullName, ".cache"); // 1.4–1.7 + } + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files + } + + /// <summary>Whether the current console supports color formatting.</summary> + private static readonly bool ConsoleSupportsColor = InteractiveInstaller.GetConsoleSupportsColor(); + + + /********* + ** Public methods + *********/ + /// <summary>Run the install or uninstall script.</summary> + /// <param name="args">The command line arguments.</param> + /// <remarks> + /// Initialisation flow: + /// 1. Collect information (mainly OS and install path) and validate it. + /// 2. Ask the user whether to install or uninstall. + /// + /// Uninstall logic: + /// 1. On Linux/Mac: if a backup of the launcher exists, delete the launcher and restore the backup. + /// 2. Delete all files and folders in the game directory matching one of the values returned by <see cref="GetUninstallPaths"/>. + /// + /// Install flow: + /// 1. Run the uninstall flow. + /// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory. + /// 3. On Linux/Mac: back up the game launcher and replace it with the SMAPI launcher. (This isn't possible on Windows, so the user needs to configure it manually.) + /// 4. Create the 'Mods' directory. + /// 5. Copy the bundled mods into the 'Mods' directory (deleting any existing versions). + /// 6. Move any mods from app data into game's mods directory. + /// </remarks> + public void Run(string[] args) + { +#if SMAPI_1_x + bool installArg = false; + bool uninstallArg = false; + string gamePathArg = null; +#else + /**** + ** read command-line arguments + ****/ + // get action from CLI + bool installArg = args.Contains("--install"); + bool uninstallArg = args.Contains("--uninstall"); + if (installArg && uninstallArg) + { + this.PrintError("You can't specify both --install and --uninstall command-line flags."); + Console.ReadLine(); + return; + } + + // get game path from CLI + string gamePathArg = null; + { + int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + gamePathArg = args[pathIndex]; + } +#endif + + /**** + ** collect details + ****/ + // get platform + Platform platform = this.DetectPlatform(); + this.PrintDebug($"Platform: {(platform == Platform.Windows ? "Windows" : "Linux or Mac")}."); + + // get game path + DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); + if (installDir == null) + { + this.PrintError("Failed finding your game path."); + Console.ReadLine(); + return; + } + + // get folders + DirectoryInfo packageDir = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", platform.ToString())); + DirectoryInfo modsDir = new DirectoryInfo(Path.Combine(installDir.FullName, "Mods")); + var paths = new + { + executable = Path.Combine(installDir.FullName, platform == Platform.Mono ? "StardewValley.exe" : "Stardew Valley.exe"), + unixSmapiLauncher = Path.Combine(installDir.FullName, "StardewModdingAPI"), + unixLauncher = Path.Combine(installDir.FullName, "StardewValley"), + unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original") + }; + this.PrintDebug($"Install path: {installDir}."); + + /**** + ** validate assumptions + ****/ + if (!packageDir.Exists) + { + 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; + } + if (!File.Exists(paths.executable)) + { + this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + Console.ReadLine(); + return; + } + + /**** + ** validate Windows dependencies + ****/ + if (platform == Platform.Windows) + { + // .NET Framework 4.5+ + if (!this.HasNetFramework45(platform)) + { + this.PrintError(Environment.OSVersion.Version >= this.Windows7Version + ? "Please install the latest version of .NET Framework before installing SMAPI." // Windows 7+ + : "Please install .NET Framework 4.5 before installing SMAPI." // Windows Vista or earlier + ); + this.PrintError("See the download page at https://www.microsoft.com/net/download/framework for details."); + Console.ReadLine(); + return; + } + if (!this.HasXNA(platform)) + { + this.PrintError("You don't seem to have XNA Framework installed. Please run the game at least once before installing SMAPI, so it can perform its first-time setup."); + Console.ReadLine(); + return; + } + } + + Console.WriteLine(); + + /**** + ** ask user what to do + ****/ + ScriptAction action; + + if (installArg) + action = ScriptAction.Install; + else if (uninstallArg) + action = ScriptAction.Uninstall; + else + { + Console.WriteLine("You can...."); + Console.WriteLine("[1] Install SMAPI."); + Console.WriteLine("[2] Uninstall SMAPI."); + Console.WriteLine(); + + string choice = this.InteractivelyChoose("What do you want to do? Type 1 or 2, then press enter.", "1", "2"); + switch (choice) + { + case "1": + action = ScriptAction.Install; + break; + case "2": + action = ScriptAction.Uninstall; + break; + default: + throw new InvalidOperationException($"Unexpected action key '{choice}'."); + } + Console.WriteLine(); + } + + /**** + ** Always uninstall old files + ****/ + // restore game launcher + if (platform == Platform.Mono && File.Exists(paths.unixLauncherBackup)) + { + this.PrintDebug("Removing SMAPI launcher..."); + this.InteractivelyDelete(paths.unixLauncher); + File.Move(paths.unixLauncherBackup, paths.unixLauncher); + } + + // remove old files + string[] removePaths = this.GetUninstallPaths(installDir, modsDir) + .Where(path => Directory.Exists(path) || File.Exists(path)) + .ToArray(); + if (removePaths.Any()) + { + this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); + foreach (string path in removePaths) + this.InteractivelyDelete(path); + } + + /**** + ** Install new files + ****/ + if (action == ScriptAction.Install) + { + // copy SMAPI files to game dir + this.PrintDebug("Adding SMAPI files..."); + foreach (FileInfo sourceFile in packageDir.EnumerateFiles()) + { + string targetPath = Path.Combine(installDir.FullName, sourceFile.Name); + this.InteractivelyDelete(targetPath); + sourceFile.CopyTo(targetPath); + } + + // replace mod launcher (if possible) + if (platform == Platform.Mono) + { + this.PrintDebug("Safely replacing game launcher..."); + if (!File.Exists(paths.unixLauncherBackup)) + File.Move(paths.unixLauncher, paths.unixLauncherBackup); + else if (File.Exists(paths.unixLauncher)) + this.InteractivelyDelete(paths.unixLauncher); + + File.Move(paths.unixSmapiLauncher, paths.unixLauncher); + } + + // create mods directory (if needed) + if (!modsDir.Exists) + { + this.PrintDebug("Creating mods directory..."); + modsDir.Create(); + } + + // add or replace bundled mods + Directory.CreateDirectory(Path.Combine(installDir.FullName, "Mods")); + DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "Mods")); + if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any()) + { + this.PrintDebug("Adding bundled mods..."); + foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories()) + { + this.PrintDebug($" adding {sourceDir.Name}..."); + + // initialise target dir + DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(modsDir.FullName, sourceDir.Name)); + this.InteractivelyDelete(targetDir.FullName); + targetDir.Create(); + + // copy files + foreach (FileInfo sourceFile in sourceDir.EnumerateFiles()) + sourceFile.CopyTo(Path.Combine(targetDir.FullName, sourceFile.Name)); + } + } + + // remove obsolete appdata mods + this.InteractivelyRemoveAppDataMods(platform, modsDir, packagedModsDir); + } + Console.WriteLine(); + + /**** + ** exit + ****/ + this.PrintColor("Done!", ConsoleColor.DarkGreen); + if (platform == Platform.Windows) + { + this.PrintColor( + action == ScriptAction.Install + ? "Don't forget to launch StardewModdingAPI.exe instead of the normal game executable. See the readme.txt for details." + : "If you manually changed shortcuts or Steam to launch SMAPI, don't forget to change those back.", + ConsoleColor.DarkGreen + ); + } + else if (action == ScriptAction.Install) + this.PrintColor("You can launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen); + Console.ReadKey(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Detect the game's platform.</summary> + /// <exception cref="NotSupportedException">The platform is not supported.</exception> + private Platform DetectPlatform() + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.MacOSX: + case PlatformID.Unix: + return Platform.Mono; + + default: + return Platform.Windows; + } + } + + /// <summary>Test whether the current console supports color formatting.</summary> + private static bool GetConsoleSupportsColor() + { + try + { + Console.ForegroundColor = Console.ForegroundColor; + return true; + } + catch (Exception) + { + return false; // Mono bug + } + } + + /// <summary>Get the value of a key in the Windows registry.</summary> + /// <param name="key">The full path of the registry key relative to HKLM.</param> + /// <param name="name">The name of the value.</param> + private string GetLocalMachineRegistryValue(string key, string name) + { + RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; + RegistryKey openKey = localMachine.OpenSubKey(key); + if (openKey == null) + return null; + using (openKey) + return (string)openKey.GetValue(name); + } + + /// <summary>Print a debug message.</summary> + /// <param name="text">The text to print.</param> + private void PrintDebug(string text) + { + this.PrintColor(text, ConsoleColor.DarkGray); + } + + /// <summary>Print a warning message.</summary> + /// <param name="text">The text to print.</param> + private void PrintWarning(string text) + { + this.PrintColor(text, ConsoleColor.DarkYellow); + } + + /// <summary>Print a warning message.</summary> + /// <param name="text">The text to print.</param> + private void PrintError(string text) + { + this.PrintColor(text, ConsoleColor.Red); + } + + /// <summary>Print a message to the console.</summary> + /// <param name="text">The message text.</param> + /// <param name="color">The text foreground color.</param> + private void PrintColor(string text, ConsoleColor color) + { + if (InteractiveInstaller.ConsoleSupportsColor) + { + Console.ForegroundColor = color; + Console.WriteLine(text); + Console.ResetColor(); + } + else + Console.WriteLine(text); + } + + /// <summary>Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows.</summary> + /// <param name="platform">The current platform.</param> + /// <exception cref="NotSupportedException">The current platform is not Windows.</exception> + private bool HasNetFramework45(Platform platform) + { + switch (platform) + { + case Platform.Windows: + using (RegistryKey versionKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full")) + return versionKey?.GetValue("Release") != null; // .NET Framework 4.5+ + + default: + throw new NotSupportedException("The installed .NET Framework version can only be checked on Windows."); + } + } + + /// <summary>Get whether the current system has XNA Framework installed. This only applies on Windows.</summary> + /// <param name="platform">The current platform.</param> + /// <exception cref="NotSupportedException">The current platform is not Windows.</exception> + private bool HasXNA(Platform platform) + { + switch (platform) + { + case Platform.Windows: + using (RegistryKey key = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry32).OpenSubKey(@"SOFTWARE\Microsoft\XNA\Framework")) + return key != null; // XNA Framework 4.0+ + + default: + throw new NotSupportedException("The installed XNA Framework version can only be checked on Windows."); + } + } + + /// <summary>Interactively delete a file or folder path, and block until deletion completes.</summary> + /// <param name="path">The file or folder path.</param> + private void InteractivelyDelete(string path) + { + while (true) + { + try + { + this.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path)); + break; + } + catch (Exception ex) + { + this.PrintError($"Oops! The installer couldn't delete {path}: [{ex.GetType().Name}] {ex.Message}."); + this.PrintError("Try rebooting your computer and then run the installer again. If that doesn't work, try deleting it yourself then press any key to retry."); + Console.ReadKey(); + } + } + } + + /// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary> + /// <param name="entry">The file or folder to reset.</param> + private void ForceDelete(FileSystemInfo entry) + { + // ignore if already deleted + entry.Refresh(); + if (!entry.Exists) + return; + + // delete children + var folder = entry as DirectoryInfo; + if (folder != null) + { + foreach (FileSystemInfo child in folder.GetFileSystemInfos()) + this.ForceDelete(child); + } + + // reset permissions & delete + entry.Attributes = FileAttributes.Normal; + entry.Delete(); + + // wait for deletion to finish + for (int i = 0; i < 10; i++) + { + entry.Refresh(); + if (entry.Exists) + Thread.Sleep(500); + } + + // throw exception if deletion didn't happen before timeout + entry.Refresh(); + if (entry.Exists) + throw new IOException($"Timed out trying to delete {entry.FullName}"); + } + + /// <summary>Interactively ask the user to choose a value.</summary> + /// <param name="message">The message to print.</param> + /// <param name="options">The allowed options (not case sensitive).</param> + private string InteractivelyChoose(string message, params string[] options) + { + while (true) + { + Console.WriteLine(message); + string input = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (!options.Contains(input)) + { + Console.WriteLine("That's not a valid option."); + continue; + } + return input; + } + } + + /// <summary>Interactively locate the game install path to update.</summary> + /// <param name="platform">The current platform.</param> + /// <param name="specifiedPath">The path specified as a command-line argument (if any), which should override automatic path detection.</param> + private DirectoryInfo InteractivelyGetInstallPath(Platform platform, string specifiedPath) + { + // get executable name + string executableFilename = platform == Platform.Windows + ? "Stardew Valley.exe" + : "StardewValley.exe"; + + // validate specified path + if (specifiedPath != null) + { + var dir = new DirectoryInfo(specifiedPath); + if (!dir.Exists) + { + this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't exist."); + return null; + } + if (!dir.EnumerateFiles(executableFilename).Any()) + { + this.PrintError($"You specified --game-path \"{specifiedPath}\", but that folder doesn't contain the Stardew Valley executable."); + return null; + } + return dir; + } + + // get installed paths + DirectoryInfo[] defaultPaths = + ( + from path in this.GetDefaultInstallPaths(platform).Distinct(StringComparer.InvariantCultureIgnoreCase) + let dir = new DirectoryInfo(path) + where dir.Exists && dir.EnumerateFiles(executableFilename).Any() + select dir + ) + .ToArray(); + + // choose where to install + if (defaultPaths.Any()) + { + // only one path + if (defaultPaths.Length == 1) + return defaultPaths.First(); + + // let user choose path + Console.WriteLine(); + Console.WriteLine("Found multiple copies of the game:"); + for (int i = 0; i < defaultPaths.Length; i++) + Console.WriteLine($"[{i + 1}] {defaultPaths[i].FullName}"); + Console.WriteLine(); + + string[] validOptions = Enumerable.Range(1, defaultPaths.Length).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray(); + string choice = this.InteractivelyChoose("Where do you want to add/remove SMAPI? Type the number next to your choice, then press enter.", validOptions); + int index = int.Parse(choice, CultureInfo.InvariantCulture) - 1; + return defaultPaths[index]; + } + + // ask user + Console.WriteLine("Oops, couldn't find the game automatically."); + while (true) + { + // get path from user + Console.WriteLine($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter."); + string path = Console.ReadLine()?.Trim(); + if (string.IsNullOrWhiteSpace(path)) + { + Console.WriteLine(" You must specify a directory path to continue."); + continue; + } + + // normalise path + if (platform == Platform.Windows) + path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path + if (platform == Platform.Mono) + path = path.Replace("\\ ", " "); // in Linux/Mac, spaces in paths may be escaped if copied from the command line + if (path.StartsWith("~/")) + { + string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE"); + path = Path.Combine(home, path.Substring(2)); + } + + // get directory + if (File.Exists(path)) + path = Path.GetDirectoryName(path); + DirectoryInfo directory = new DirectoryInfo(path); + + // validate path + if (!directory.Exists) + { + Console.WriteLine(" That directory doesn't seem to exist."); + continue; + } + if (!directory.EnumerateFiles(executableFilename).Any()) + { + Console.WriteLine(" That directory doesn't contain a Stardew Valley executable."); + continue; + } + + // looks OK + Console.WriteLine(" OK!"); + return directory; + } + } + + /// <summary>Interactively move mods out of the appdata directory.</summary> + /// <param name="platform">The current platform.</param> + /// <param name="properModsDir">The directory which should contain all mods.</param> + /// <param name="packagedModsDir">The installer directory containing packaged mods.</param> + private void InteractivelyRemoveAppDataMods(Platform platform, DirectoryInfo properModsDir, DirectoryInfo packagedModsDir) + { + // get packaged mods to delete + string[] packagedModNames = packagedModsDir.GetDirectories().Select(p => p.Name).ToArray(); + + // get path + string homePath = platform == Platform.Windows + ? Environment.GetEnvironmentVariable("APPDATA") + : Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".config"); + string appDataPath = Path.Combine(homePath, "StardewValley"); + DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods")); + + // check if migration needed + if (!modDir.Exists) + return; + this.PrintDebug($"Found an obsolete mod path: {modDir.FullName}"); + this.PrintDebug(" Support for mods here was dropped in SMAPI 1.0 (it was never officially supported)."); + + // move mods if no conflicts (else warn) + foreach (FileSystemInfo entry in modDir.EnumerateFileSystemInfos()) + { + // get type + bool isDir = entry is DirectoryInfo; + if (!isDir && !(entry is FileInfo)) + continue; // should never happen + + // delete packaged mods (newer version bundled into SMAPI) + if (isDir && packagedModNames.Contains(entry.Name, StringComparer.InvariantCultureIgnoreCase)) + { + this.PrintDebug($" Deleting {entry.Name} because it's bundled into SMAPI..."); + this.InteractivelyDelete(entry.FullName); + continue; + } + + // check paths + string newPath = Path.Combine(properModsDir.FullName, entry.Name); + if (isDir ? Directory.Exists(newPath) : File.Exists(newPath)) + { + this.PrintWarning($" Can't move {entry.Name} because it already exists in your game's mod directory."); + continue; + } + + // move into mods + this.PrintDebug($" Moving {entry.Name} into the game's mod directory..."); + this.Move(entry, newPath); + } + + // delete if empty + if (modDir.EnumerateFileSystemInfos().Any()) + this.PrintWarning(" You have files in this folder which couldn't be moved automatically. These will be ignored by SMAPI."); + else + { + this.PrintDebug(" Deleted empty directory."); + modDir.Delete(); + } + } + + /// <summary>Move a filesystem entry to a new parent directory.</summary> + /// <param name="entry">The filesystem entry to move.</param> + /// <param name="newPath">The destination path.</param> + /// <remarks>We can't use <see cref="FileInfo.MoveTo"/> or <see cref="DirectoryInfo.MoveTo"/>, because those don't work across partitions.</remarks> + private void Move(FileSystemInfo entry, string newPath) + { + // file + if (entry is FileInfo file) + { + file.CopyTo(newPath); + file.Delete(); + } + + // directory + else + { + Directory.CreateDirectory(newPath); + + DirectoryInfo directory = (DirectoryInfo)entry; + foreach (FileSystemInfo child in directory.EnumerateFileSystemInfos()) + this.Move(child, Path.Combine(newPath, child.Name)); + + directory.Delete(); + } + } + } +} diff --git a/src/StardewModdingAPI.Installer/Program.cs b/src/StardewModdingAPI.Installer/Program.cs new file mode 100644 index 00000000..8f328ecf --- /dev/null +++ b/src/StardewModdingAPI.Installer/Program.cs @@ -0,0 +1,17 @@ +namespace StardewModdingApi.Installer +{ + /// <summary>The entry point for SMAPI's install and uninstall console app.</summary> + internal class Program + { + /********* + ** Public methods + *********/ + /// <summary>Run the install or uninstall script.</summary> + /// <param name="args">The command line arguments.</param> + public static void Main(string[] args) + { + var installer = new InteractiveInstaller(); + installer.Run(args); + } + } +} diff --git a/src/StardewModdingAPI.Installer/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.Installer/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3a6a4648 --- /dev/null +++ b/src/StardewModdingAPI.Installer/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("StardewModdingAPI.Installer")] +[assembly: AssemblyProduct("StardewModdingAPI.Installer")] +[assembly: Guid("443ddf81-6aaf-420a-a610-3459f37e5575")] diff --git a/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj b/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj new file mode 100644 index 00000000..58ce519c --- /dev/null +++ b/src/StardewModdingAPI.Installer/StardewModdingAPI.Installer.csproj @@ -0,0 +1,56 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">x86</Platform> + <ProjectGuid>{443DDF81-6AAF-420A-A610-3459F37E5575}</ProjectGuid> + <OutputType>Exe</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>StardewModdingAPI.Installer</RootNamespace> + <AssemblyName>StardewModdingAPI.Installer</AssemblyName> + <TargetFrameworkVersion>v4.0</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> + <PlatformTarget>x86</PlatformTarget> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Debug\Installer</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> + <PlatformTarget>x86</PlatformTarget> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Release\Installer</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <ItemGroup> + <Reference Include="System" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="Enums\ScriptAction.cs" /> + <Compile Include="Enums\Platform.cs" /> + <Compile Include="InteractiveInstaller.cs" /> + <Compile Include="Program.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + </ItemGroup> + <ItemGroup> + <Content Include="readme.txt"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <Import Project="$(SolutionDir)\common.targets" /> + <Import Project="$(SolutionDir)\prepare-install-package.targets" /> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Installer/readme.txt b/src/StardewModdingAPI.Installer/readme.txt new file mode 100644 index 00000000..eb27ac52 --- /dev/null +++ b/src/StardewModdingAPI.Installer/readme.txt @@ -0,0 +1,44 @@ + ___ ___ ___ ___ + / /\ /__/\ / /\ / /\ ___ + / /:/_ | |::\ / /::\ / /::\ / /\ + / /:/ /\ | |:|:\ / /:/\:\ / /:/\:\ / /:/ + / /:/ /::\ __|__|:|\:\ / /:/~/::\ / /:/~/:/ /__/::\ + /__/:/ /:/\:\ /__/::::| \:\ /__/:/ /:/\:\ /__/:/ /:/ \__\/\:\__ + \ \:\/:/~/:/ \ \:\~~\__\/ \ \:\/:/__\/ \ \:\/:/ \ \:\/\ + \ \::/ /:/ \ \:\ \ \::/ \ \::/ \__\::/ + \__\/ /:/ \ \:\ \ \:\ \ \:\ /__/:/ + /__/:/ \ \:\ \ \:\ \ \:\ \__\/ + \__\/ \__\/ \__\/ \__\/ + + +SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separately. + + +Install guide +-------------------------------- +See http://stardewvalleywiki.com/Modding:Installing_SMAPI. + + +Need help? +-------------------------------- +- FAQs: http://stardewvalleywiki.com/Modding:Player_FAQs +- Ask for help: https://discord.gg/kH55QXP + + +Manual install +-------------------------------- +THIS IS NOT RECOMMENDED FOR MOST PLAYERS. See instructions above instead. +If you really want to install SMAPI manually, here's how. + +1. Download the latest version of SMAPI: https://github.com/Pathoschild/SMAPI/releases. +2. Unzip the .zip file somewhere (not in your game folder). +3. Copy the files from the "internal/Windows" folder (on Windows) or "internal/Mono" folder (on + Linux/Mac) into your game folder. The `StardewModdingAPI.exe` file should be right next to the + game's executable. +4. + - Windows only: if you use Steam, see the install guide above to enable achievements and + overlay. Otherwise, just run StardewModdingAPI.exe in your game folder to play with mods. + + - Linux/Mac only: rename the "StardewValley" file (no extension) to "StardewValley-original", and + "StardewModdingAPI" (no extension) to "StardewValley". Now just launch the game as usual to + play with mods. diff --git a/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs new file mode 100644 index 00000000..fc84ca29 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Core/ModResolverTests.cs @@ -0,0 +1,556 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Tests.Core +{ + /// <summary>Unit tests for <see cref="ModResolver"/>.</summary> + [TestFixture] + public class ModResolverTests + { + /********* + ** Unit tests + *********/ + /**** + ** ReadManifests + ****/ + [Test(Description = "Assert that the resolver correctly returns an empty list if there are no mods installed.")] + public void ReadBasicManifest_NoMods_ReturnsEmptyList() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(rootFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); + } + + [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] + public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() + { + // arrange + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); + Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed."); + Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); + } + + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] + public void ReadBasicManifest_CanReadFile() + { + // create manifest data + IDictionary<string, object> originalDependency = new Dictionary<string, object> + { + [nameof(IManifestDependency.UniqueID)] = Sample.String() + }; + IDictionary<string, object> original = new Dictionary<string, object> + { + [nameof(IManifest.Name)] = Sample.String(), + [nameof(IManifest.Author)] = Sample.String(), + [nameof(IManifest.Version)] = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + [nameof(IManifest.Description)] = Sample.String(), + [nameof(IManifest.UniqueID)] = $"{Sample.String()}.{Sample.String()}", + [nameof(IManifest.EntryDll)] = $"{Sample.String()}.dll", + [nameof(IManifest.MinimumApiVersion)] = $"{Sample.Int()}.{Sample.Int()}-{Sample.String()}", + [nameof(IManifest.Dependencies)] = new[] { originalDependency }, + ["ExtraString"] = Sample.String(), + ["ExtraInt"] = Sample.Int() + }; + + // write to filesystem + string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); + string filename = Path.Combine(modFolder, "manifest.json"); + Directory.CreateDirectory(modFolder); + File.WriteAllText(filename, JsonConvert.SerializeObject(original)); + + // act + IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModCompatibility[0], new DisabledMod[0]).ToArray(); + IModMetadata mod = mods.FirstOrDefault(); + + // assert + Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); + Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); + Assert.AreEqual(null, mod.Compatibility, "The compatibility record should be null since we didn't provide one."); + Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); + Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); + Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); + + Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name."); + Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Author)], mod.Manifest.Author, "The manifest's author doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Description)], mod.Manifest.Description, "The manifest's description doesn't match."); + Assert.AreEqual(original[nameof(IManifest.EntryDll)], mod.Manifest.EntryDll, "The manifest's entry DLL doesn't match."); + Assert.AreEqual(original[nameof(IManifest.MinimumApiVersion)], mod.Manifest.MinimumApiVersion?.ToString(), "The manifest's minimum API version doesn't match."); + Assert.AreEqual(original[nameof(IManifest.Version)]?.ToString(), mod.Manifest.Version?.ToString(), "The manifest's version doesn't match."); + + Assert.IsNotNull(mod.Manifest.ExtraFields, "The extra fields should not be null."); + Assert.AreEqual(2, mod.Manifest.ExtraFields.Count, "The extra fields should contain two values."); + 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."); + + 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."); + } + + /**** + ** ValidateManifests + ****/ + [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] + public void ValidateManifests_NoMods_DoesNothing() + { + new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0")); + } + + [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] + public void ValidateManifests_Skips_Failed() + { + // arrange + Mock<IModMetadata> mock = this.GetMetadata("Mod A"); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); + } + + [Test(Description = "Assert that validation fails if the mod has 'assume broken' compatibility.")] + public void ValidateManifests_ModCompatibility_AssumeBroken_Fails() + { + // arrange + Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + this.SetupMetadataForValidation(mock, new ModCompatibility { Compatibility = ModCompatibilityType.AssumeBroken, UpperVersion = new SemanticVersion("1.0"), UpdateUrls = new[] { "http://example.org" }}); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the minimum API version is higher than the current SMAPI version.")] + public void ValidateManifests_MinimumApiVersion_Fails() + { + // arrange + Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = new SemanticVersion("1.1"))); + this.SetupMetadataForValidation(mock); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + } + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_MissingEntryDLL_Fails() + { + // arrange + Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.EntryDll = "Missing.dll"), allowStatusChange: true); + this.SetupMetadataForValidation(mock); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + } + +#if !SMAPI_1_x + [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] + public void ValidateManifests_DuplicateUniqueID_Fails() + { + // arrange + Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.Name = "Mod B"), allowStatusChange: true); + Mock<IModMetadata> modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false); + foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC }) + this.SetupMetadataForValidation(mod); + + // act + new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID."); + } +#endif + + [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] + public void ValidateManifests_Valid_Passes() + { + // set up manifest + IManifest manifest = this.GetManifest(); + + // create DLL + string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(modFolder); + File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); + + // arrange + Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); + mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mock.Setup(p => p.Compatibility).Returns(() => null); + mock.Setup(p => p.Manifest).Returns(manifest); + mock.Setup(p => p.DirectoryPath).Returns(modFolder); + + // act + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0")); + + // assert + // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. + } + + /**** + ** ProcessDependencies + ****/ + [Test(Description = "Assert that processing dependencies doesn't fail if there are no mods installed.")] + public void ProcessDependencies_NoMods_DoesNothing() + { + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0]).ToArray(); + + // assert + Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); + } + + [Test(Description = "Assert that processing dependencies doesn't change the order if there are no mod dependencies.")] + public void ProcessDependencies_NoDependencies_DoesNothing() + { + // arrange + // A B C + Mock<IModMetadata> modA = this.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B"); + Mock<IModMetadata> modC = this.GetMetadata("Mod C"); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object, modC.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order unexpectedly changed with no dependencies."); + Assert.AreSame(modB.Object, mods[1], "The load order unexpectedly changed with no dependencies."); + 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() + { + // arrange + // A ◀── B + // ▲ ▲ + // │ │ + // └─ C ─┘ + Mock<IModMetadata> modA = this.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod A", "Mod B" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(3, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since the other mods depend on it."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs both mod A and mod B."); + } + + [Test(Description = "Assert that simple dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_DependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + Mock<IModMetadata> modA = this.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + + // 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(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + } + + [Test(Description = "Assert that overlapping dependency chains are reordered correctly.")] + public void ProcessDependencies_Reorders_OverlappingDependencyChain() + { + // arrange + // A ◀── B ◀── C ◀── D + // ▲ ▲ + // │ │ + // E ◀── F + Mock<IModMetadata> modA = this.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }); + Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod C" }); + Mock<IModMetadata> modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod B" }); + Mock<IModMetadata> modF = this.GetMetadata("Mod F", dependencies: new[] { "Mod C", "Mod E" }); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modF.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(6, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A, and is needed by mod C."); + Assert.AreSame(modC.Object, mods[2], "The load order is incorrect: mod C should be third since it needs mod B, and is needed by mod D."); + Assert.AreSame(modD.Object, mods[3], "The load order is incorrect: mod D should be fourth since it needs mod C."); + Assert.AreSame(modE.Object, mods[4], "The load order is incorrect: mod E should be fifth since it needs mod B, but is specified after C which also needs mod B."); + Assert.AreSame(modF.Object, mods[5], "The load order is incorrect: mod F should be last since it needs mods E and C."); + } + + [Test(Description = "Assert that mods with circular dependency chains are skipped, but any other mods are loaded in the correct order.")] + public void ProcessDependencies_Skips_CircularDependentMods() + { + // arrange + // A ◀── B ◀── C ──▶ D + // ▲ │ + // │ ▼ + // └──── E + Mock<IModMetadata> modA = this.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B", "Mod D" }, allowStatusChange: true); + Mock<IModMetadata> modD = this.GetMetadata("Mod D", dependencies: new[] { "Mod E" }, allowStatusChange: true); + Mock<IModMetadata> modE = this.GetMetadata("Mod E", dependencies: new[] { "Mod C" }, allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modC.Object, modA.Object, modB.Object, modD.Object, modE.Object }).ToArray(); + + // assert + Assert.AreEqual(5, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + modC.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod C was expected to fail since it's part of a dependency loop."); + 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."); + } + + [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.GetMetadata("Mod A"); + Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); + Mock<IModMetadata> modC = this.GetMetadata("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."); + } + + [Test(Description = "Assert that dependencies are failed if they don't meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_FailsIfNotMet() + { + // arrange + // A 1.0 ◀── B (need A 1.1) + Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<string>()), Times.Once, "Mod B unexpectedly didn't fail even though it needs a newer version of Mod A."); + } + + [Test(Description = "Assert that dependencies are accepted if they meet the minimum version.")] + public void ProcessDependencies_WithMinVersions_SucceedsIfMet() + { + // arrange + // A 1.0 ◀── B (need A 1.0-beta) + Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + } + +#if !SMAPI_1_x + [Test(Description = "Assert that optional dependencies are sorted correctly if present.")] + public void ProcessDependencies_IfOptional() + { + // arrange + // A ◀── B + Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0")); + Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }).ToArray(); + + // assert + Assert.AreEqual(2, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modA.Object, mods[0], "The load order is incorrect: mod A should be first since it's needed by mod B."); + Assert.AreSame(modB.Object, mods[1], "The load order is incorrect: mod B should be second since it needs mod A."); + } + + [Test(Description = "Assert that optional dependencies are accepted if they're missing.")] + public void ProcessDependencies_IfOptional_SucceedsIfMissing() + { + // arrange + // A ◀── B where A doesn't exist + Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false); + + // act + IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }).ToArray(); + + // assert + Assert.AreEqual(1, mods.Length, 0, "Expected to get the same number of mods input."); + Assert.AreSame(modB.Object, mods[0], "The load order is incorrect: mod B should be first since it's the only mod."); + } +#endif + + + /********* + ** Private methods + *********/ + /// <summary>Get a randomised basic manifest.</summary> + /// <param name="adjust">Adjust the generated manifest.</param> + private Manifest GetManifest(Action<Manifest> adjust = null) + { + Manifest manifest = new Manifest + { + Name = Sample.String(), + Author = Sample.String(), + Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + Description = Sample.String(), + UniqueID = $"{Sample.String()}.{Sample.String()}", + EntryDll = $"{Sample.String()}.dll" + }; + adjust?.Invoke(manifest); + return manifest; + } + + /// <summary>Get a randomised basic manifest.</summary> + /// <param name="uniqueID">The mod's name and unique ID.</param> + /// <param name="version">The mod version.</param> + /// <param name="adjust">Adjust the generated manifest.</param> + /// <param name="dependencies">The dependencies this mod requires.</param> + private IManifest GetManifest(string uniqueID, string version, Action<Manifest> adjust, params IManifestDependency[] dependencies) + { + return this.GetManifest(manifest => + { + manifest.Name = uniqueID; + manifest.UniqueID = uniqueID; + manifest.Version = new SemanticVersion(version); + manifest.Dependencies = dependencies; + adjust?.Invoke(manifest); + }); + } + + /// <summary>Get a randomised basic manifest.</summary> + /// <param name="uniqueID">The mod's name and unique ID.</param> + /// <param name="version">The mod version.</param> + /// <param name="dependencies">The dependencies this mod requires.</param> + private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies) + { + return this.GetManifest(uniqueID, version, null, dependencies); + } + + /// <summary>Get a randomised basic manifest.</summary> + /// <param name="uniqueID">The mod's name and unique ID.</param> + private Mock<IModMetadata> GetMetadata(string uniqueID) + { + return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); + } + + /// <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> + /// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> + private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) + { + IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray()); + return this.GetMetadata(manifest, allowStatusChange); + } + + /// <summary>Get a randomised basic manifest.</summary> + /// <param name="manifest">The mod manifest.</param> + /// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> + private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false) + { + Mock<IModMetadata> mod = new Mock<IModMetadata>(MockBehavior.Strict); + mod.Setup(p => p.Compatibility).Returns(() => null); + mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); + mod.Setup(p => p.Manifest).Returns(manifest); + if (allowStatusChange) + { + mod + .Setup(p => p.SetStatus(It.IsAny<ModMetadataStatus>(), It.IsAny<string>())) + .Callback<ModMetadataStatus, string>((status, message) => Console.WriteLine($"<{manifest.UniqueID} changed status: [{status}] {message}")) + .Returns(mod.Object); + } + return mod; + } + + /// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary> + /// <param name="mod">The mock mod metadata.</param> + /// <param name="compatibility">The compatibility record to set.</param> + private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModCompatibility compatibility = null) + { + mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); + mod.Setup(p => p.Compatibility).Returns(() => null); + mod.Setup(p => p.Manifest).Returns(this.GetManifest()); + mod.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); + mod.Setup(p => p.Compatibility).Returns(compatibility); + } + } +} diff --git a/src/StardewModdingAPI.Tests/Core/TranslationTests.cs b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs new file mode 100644 index 00000000..8511e765 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Core/TranslationTests.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.ModHelpers; +using StardewValley; + +namespace StardewModdingAPI.Tests.Core +{ + /// <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("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + Translation translation = helper.Get("key"); + Translation[] translationList = helper.GetTranslations()?.ToArray(); + + // 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(translationList, "The full list of translations is unexpectedly null."); + Assert.AreEqual(0, translationList.Length, "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, Translation[]>(); + TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + actual[locale] = helper.GetTranslations()?.ToArray(); + } + + // 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]).Using<Translation, Translation>(this.CompareEquality), $"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, Translation[]>(); + TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + foreach (string locale in expected.Keys) + { + this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); + + List<Translation> translations = new List<Translation>(); + foreach (Translation translation in expected[locale]) + translations.Add(helper.Get(translation.Key)); + actual[locale] = translations.ToArray(); + } + + // 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]).Using<Translation, Translation>(this.CompareEquality), $"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, Translation[]> GetExpectedTranslations() + { + var expected = new Dictionary<string, Translation[]> + { + ["default"] = new[] + { + new Translation(string.Empty, "default", "key A", "default A"), + new Translation(string.Empty, "default", "key C", "default C") + }, + ["en"] = new[] + { + new Translation(string.Empty, "en", "key A", "en A"), + new Translation(string.Empty, "en", "key B", "en B"), + new Translation(string.Empty, "en", "key C", "default C") + }, + ["zzz"] = new[] + { + new Translation(string.Empty, "zzz", "key A", "zzz A"), + new Translation(string.Empty, "zzz", "key C", "default C") + } + }; + expected["en-us"] = expected["en"].ToArray(); + return expected; + } + + /// <summary>Get whether two translations have the same public values.</summary> + /// <param name="a">The first translation to compare.</param> + /// <param name="b">The second translation to compare.</param> + private bool CompareEquality(Translation a, Translation b) + { + return a.Key == b.Key && a.ToString() == b.ToString(); + } + + /// <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.Tests/Properties/AssemblyInfo.cs b/src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..ee09145b --- /dev/null +++ b/src/StardewModdingAPI.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("StardewModdingAPI.Tests")] +[assembly: AssemblyDescription("")] +[assembly: Guid("36ccb19e-92eb-48c7-9615-98eefd45109b")] diff --git a/src/StardewModdingAPI.Tests/Sample.cs b/src/StardewModdingAPI.Tests/Sample.cs new file mode 100644 index 00000000..99835d92 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Sample.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Tests +{ + /// <summary>Provides sample values for unit testing.</summary> + internal static class Sample + { + /********* + ** Properties + *********/ + /// <summary>A random number generator.</summary> + private static readonly Random Random = new Random(); + + + /********* + ** Properties + *********/ + /// <summary>Get a sample string.</summary> + public static string String() + { + return Guid.NewGuid().ToString("N"); + } + + /// <summary>Get a sample integer.</summary> + public static int Int() + { + return Sample.Random.Next(); + } + } +} diff --git a/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj new file mode 100644 index 00000000..f3dbcdd4 --- /dev/null +++ b/src/StardewModdingAPI.Tests/StardewModdingAPI.Tests.csproj @@ -0,0 +1,69 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">x86</Platform> + <ProjectGuid>{36CCB19E-92EB-48C7-9615-98EEFD45109B}</ProjectGuid> + <OutputType>Library</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>StardewModdingAPI.Tests</RootNamespace> + <AssemblyName>StardewModdingAPI.Tests</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>false</Optimize> + <OutputPath>bin\Debug\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>bin\Release\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + </PropertyGroup> + <ItemGroup> + <Reference Include="Castle.Core, Version=4.1.1.0, Culture=neutral, PublicKeyToken=407dd0808d44fbdc, processorArchitecture=MSIL"> + <HintPath>..\packages\Castle.Core.4.1.1\lib\net45\Castle.Core.dll</HintPath> + </Reference> + <Reference Include="Moq, Version=4.7.99.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL"> + <HintPath>..\packages\Moq.4.7.99\lib\net45\Moq.dll</HintPath> + </Reference> + <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> + <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> + </Reference> + <Reference Include="nunit.framework, Version=3.7.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> + <HintPath>..\packages\NUnit.3.7.1\lib\net45\nunit.framework.dll</HintPath> + </Reference> + <Reference Include="System" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="Utilities\SemanticVersionTests.cs" /> + <Compile Include="Utilities\SDateTests.cs" /> + <Compile Include="Core\TranslationTests.cs" /> + <Compile Include="Core\ModResolverTests.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Sample.cs" /> + </ItemGroup> + <ItemGroup> + <None Include="packages.config" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI\StardewModdingAPI.csproj"> + <Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project> + <Name>StardewModdingAPI</Name> + </ProjectReference> + </ItemGroup> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <Import Project="$(SolutionDir)\common.targets" /> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs new file mode 100644 index 00000000..25acbaf3 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Utilities/SDateTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Tests.Utilities +{ + /// <summary>Unit tests for <see cref="SDate"/>.</summary> + [TestFixture] + internal class SDateTests + { + /********* + ** Properties + *********/ + /// <summary>All valid seasons.</summary> + private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + + /// <summary>All valid days of a month.</summary> + private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); + + /// <summary>Sample relative dates for test cases.</summary> + private static class Dates + { + /// <summary>The base date to which other dates are relative.</summary> + public const string Now = "02 summer Y2"; + + /// <summary>The day before <see cref="Now"/>.</summary> + public const string PrevDay = "01 summer Y2"; + + /// <summary>The month before <see cref="Now"/>.</summary> + public const string PrevMonth = "02 spring Y2"; + + /// <summary>The year before <see cref="Now"/>.</summary> + public const string PrevYear = "02 summer Y1"; + + /// <summary>The day after <see cref="Now"/>.</summary> + public const string NextDay = "03 summer Y2"; + + /// <summary>The month after <see cref="Now"/>.</summary> + public const string NextMonth = "02 fall Y2"; + + /// <summary>The year after <see cref="Now"/>.</summary> + public const string NextYear = "02 summer Y3"; + } + + + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + { + // act + SDate date = new SDate(day, season, year); + + // assert + Assert.AreEqual(day, date.Day); + Assert.AreEqual(season, date.Season); + Assert.AreEqual(year, date.Year); + } + + [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] + [TestCase(01, "Spring", 1)] // seasons are case-sensitive + [TestCase(01, "springs", 1)] // invalid season name + [TestCase(-1, "spring", 1)] // day < 0 + [TestCase(29, "spring", 1)] // day > 28 + [TestCase(01, "spring", -1)] // year < 1 + [TestCase(01, "spring", 0)] // year < 1 + [SuppressMessage("ReSharper", "AssignmentIsFullyDiscarded", Justification = "Deliberate for unit test.")] + public void Constructor_RejectsInvalidValues(int day, string season, int year) + { + // act & assert + Assert.Throws<ArgumentException>(() => _ = new SDate(day, season, year), "Constructing the invalid date didn't throw the expected exception."); + } + + /**** + ** ToString + ****/ + [Test(Description = "Assert that ToString returns the expected string.")] + [TestCase("14 spring Y1", ExpectedResult = "14 spring Y1")] + [TestCase("01 summer Y16", ExpectedResult = "01 summer Y16")] + [TestCase("28 fall Y10", ExpectedResult = "28 fall Y10")] + [TestCase("01 winter Y1", ExpectedResult = "01 winter Y1")] + public string ToString(string dateStr) + { + return this.GetDate(dateStr).ToString(); + } + + /**** + ** AddDays + ****/ + [Test(Description = "Assert that AddDays returns the expected date.")] + [TestCase("01 spring Y1", 15, ExpectedResult = "16 spring Y1")] // day transition + [TestCase("01 spring Y1", 28, ExpectedResult = "01 summer Y1")] // season transition + [TestCase("01 spring Y1", 28 * 4, ExpectedResult = "01 spring Y2")] // year transition + [TestCase("01 spring Y1", 28 * 7 + 17, ExpectedResult = "18 winter Y2")] // year transition + [TestCase("15 spring Y1", -14, ExpectedResult = "01 spring Y1")] // negative day transition + [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition + [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition + [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition + [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y3")] // test for zero-index errors + [TestCase("06 fall Y2", 51, ExpectedResult = "01 spring Y3")] // test for zero-index errors + public string AddDays(string dateStr, int addDays) + { + return this.GetDate(dateStr).AddDays(addDays).ToString(); + } + + /**** + ** GetHashCode + ****/ + [Test(Description = "Assert that GetHashCode returns a unique ordered value for every date.")] + public void GetHashCode_ReturnsUniqueOrderedValue() + { + IDictionary<int, SDate> hashes = new Dictionary<int, SDate>(); + int lastHash = int.MinValue; + for (int year = 1; year <= 4; year++) + { + foreach (string season in SDateTests.ValidSeasons) + { + foreach (int day in SDateTests.ValidDays) + { + SDate date = new SDate(day, season, year); + int hash = date.GetHashCode(); + if (hashes.TryGetValue(hash, out SDate otherDate)) + Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); + if (hash < lastHash) + Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); + + lastHash = hash; + hashes[hash] = date; + } + } + } + } + + [Test(Description = "Assert that the == operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_Equals(string now, string other) + { + return this.GetDate(now) == this.GetDate(other); + } + + [Test(Description = "Assert that the != operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_NotEquals(string now, string other) + { + return this.GetDate(now) != this.GetDate(other); + } + + [Test(Description = "Assert that the < operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThan(string now, string other) + { + return this.GetDate(now) < this.GetDate(other); + } + + [Test(Description = "Assert that the <= operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] + public bool Operators_LessThanOrEqual(string now, string other) + { + return this.GetDate(now) <= this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThan(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + [Test(Description = "Assert that the > operator returns the expected values. We only need a few test cases, since it's based on GetHashCode which is tested more thoroughly.")] + [TestCase(Dates.Now, null, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.PrevDay, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevMonth, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.PrevYear, ExpectedResult = true)] + [TestCase(Dates.Now, Dates.Now, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] + [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] + public bool Operators_MoreThanOrEqual(string now, string other) + { + return this.GetDate(now) > this.GetDate(other); + } + + + /********* + ** Private methods + *********/ + /// <summary>Convert a string date into a game date, to make unit tests easier to read.</summary> + /// <param name="dateStr">The date string like "dd MMMM yy".</param> + private SDate GetDate(string dateStr) + { + if (dateStr == null) + return null; + + void Fail(string reason) => throw new AssertionException($"Couldn't parse date '{dateStr}' because {reason}."); + + // parse + Match match = Regex.Match(dateStr, @"^(?<day>\d+) (?<season>\w+) Y(?<year>\d+)$"); + if (!match.Success) + Fail("it doesn't match expected pattern (should be like 28 spring Y1)"); + + // extract parts + string season = match.Groups["season"].Value; + if (!int.TryParse(match.Groups["day"].Value, out int day)) + Fail($"'{match.Groups["day"].Value}' couldn't be parsed as a day."); + if (!int.TryParse(match.Groups["year"].Value, out int year)) + Fail($"'{match.Groups["year"].Value}' couldn't be parsed as a year."); + + // build date + return new SDate(day, season, year); + } + } +} diff --git a/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs new file mode 100644 index 00000000..03cd26c9 --- /dev/null +++ b/src/StardewModdingAPI.Tests/Utilities/SemanticVersionTests.cs @@ -0,0 +1,302 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; +using NUnit.Framework; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Tests.Utilities +{ + /// <summary>Unit tests for <see cref="SemanticVersion"/>.</summary> + [TestFixture] + internal class SemanticVersionTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [TestCase("1.0", ExpectedResult = "1.0")] + [TestCase("1.0.0", ExpectedResult = "1.0")] + [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] + [TestCase("1.2-some-tag.4", ExpectedResult = "1.2-some-tag.4")] + [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + public string Constructor_FromString(string input) + { + return new SemanticVersion(input).ToString(); + } + + [Test(Description = "Assert that the constructor sets the expected values for all valid versions.")] + [TestCase(1, 0, 0, null, ExpectedResult = "1.0")] + [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")] + [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")] + [TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] + [TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] + public string Constructor_FromParts(int major, int minor, int patch, string tag) + { + // act + ISemanticVersion version = new SemanticVersion(major, minor, patch, tag); + + // assert + Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value."); + Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value."); + Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value."); + Assert.AreEqual(string.IsNullOrWhiteSpace(tag) ? null : tag.Trim(), version.Build, "The tag doesn't match the given value."); + return version.ToString(); + } + + [Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("1")] + [TestCase("01.0")] + [TestCase("1.05")] + [TestCase("1.5.06")] // leading zeros specifically prohibited by spec + [TestCase("1.2.3.4")] + [TestCase("1.apple")] + [TestCase("1.2.apple")] + [TestCase("1.2.3.apple")] + [TestCase("1..2..3")] + [TestCase("1.2.3-")] + [TestCase("1.2.3-some-tag...")] + [TestCase("1.2.3-some-tag...4")] + [TestCase("apple")] + [TestCase("-apple")] + [TestCase("-5")] + public void Constructor_FromString_WithInvalidValues(string input) + { + if (input == null) + this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input)); + else + this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); + } + + /**** + ** CompareTo + ****/ + [Test(Description = "Assert that version.CompareTo returns the expected value.")] + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = 0)] + [TestCase("1.0", "1.0", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = 0)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = 0)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = 0)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = -1)] + [TestCase("1.0", "1.1", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0", ExpectedResult = -1)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = -1)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = -1)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = -1)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)] + [TestCase("1.1", "1.0", ExpectedResult = 1)] + [TestCase("1.0", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = 1)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] + public int CompareTo(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.CompareTo(versionB); + } + + /**** + ** IsOlderThan + ****/ + [Test(Description = "Assert that version.IsOlderThan returns the expected value.")] + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = true)] + [TestCase("1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0", ExpectedResult = true)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = true)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = false)] + [TestCase("1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] + public bool IsOlderThan(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.IsOlderThan(versionB); + } + + /**** + ** IsNewerThan + ****/ + [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + // keep test cases in sync with CompareTo for simplicity. + // equal + [TestCase("0.5.7", "0.5.7", ExpectedResult = false)] + [TestCase("1.0", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta", ExpectedResult = false)] + [TestCase("1.0-beta.10", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta ", ExpectedResult = false)] + + // less than + [TestCase("0.5.7", "0.5.8", ExpectedResult = false)] + [TestCase("1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.1", "1.0-beta.2", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", ExpectedResult = false)] + + // more than + [TestCase("0.5.8", "0.5.7", ExpectedResult = true)] + [TestCase("1.1", "1.0", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)] + [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)] + [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] + public bool IsNewerThan(string versionStrA, string versionStrB) + { + ISemanticVersion versionA = new SemanticVersion(versionStrA); + ISemanticVersion versionB = new SemanticVersion(versionStrB); + return versionA.IsNewerThan(versionB); + } + + /**** + ** IsBetween + ****/ + [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + // is between + [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] + [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)] + [TestCase("1.0", "1.0-beta", "1.1", ExpectedResult = true)] + [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)] + [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)] + [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)] + + // is not between + [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)] + [TestCase("1.0", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)] + [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)] + [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] + public bool IsBetween(string versionStr, string lowerStr, string upperStr) + { + ISemanticVersion lower = new SemanticVersion(lowerStr); + ISemanticVersion upper = new SemanticVersion(upperStr); + ISemanticVersion version = new SemanticVersion(versionStr); + return version.IsBetween(lower, upper); + } + + /**** + ** Serialisable + ****/ + [Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")] + [TestCase("1.0")] + public void Serialisable(string versionStr) + { + // act + string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); + SemanticVersion after = JsonConvert.DeserializeObject<SemanticVersion>(json); + + // assert + Assert.IsNotNull(after, "The semantic version after deserialisation is unexpectedly null."); + Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialisation doesn't match the input version."); + } + + /**** + ** GameVersion + ****/ + [Test(Description = "Assert that the GameVersion subclass correctly parses legacy game versions.")] + [TestCase("1.0")] + [TestCase("1.01")] + [TestCase("1.02")] + [TestCase("1.03")] + [TestCase("1.04")] + [TestCase("1.05")] + [TestCase("1.051")] + [TestCase("1.051b")] + [TestCase("1.06")] + [TestCase("1.07")] + [TestCase("1.07a")] + [TestCase("1.1")] + [TestCase("1.11")] + [TestCase("1.2")] + [TestCase("1.2.15")] + public void GameVersion(string versionStr) + { + // act + GameVersion version = new GameVersion(versionStr); + + // assert + Assert.AreEqual(versionStr, version.ToString(), "The game version did not round-trip to the same value."); + Assert.IsTrue(version.IsOlderThan(new SemanticVersion("1.2.30")), "The game version should be considered older than the later semantic versions."); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary> + /// <typeparam name="T">The expected exception type.</typeparam> + /// <param name="action">The action which may throw the exception.</param> + /// <param name="message">The message to log if the expected exception isn't thrown.</param> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException<T>(Func<object> action, string message = null) + where T : Exception + { + this.AssertAndLogException<T>(() => + { + object result = action(); + TestContext.WriteLine($"Func result: {result}"); + }); + } + + /// <summary>Assert that the expected exception type is thrown, and log the thrown exception.</summary> + /// <typeparam name="T">The expected exception type.</typeparam> + /// <param name="action">The action which may throw the exception.</param> + /// <param name="message">The message to log if the expected exception isn't thrown.</param> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] + private void AssertAndLogException<T>(Action action, string message = null) + where T : Exception + { + try + { + action(); + } + catch (T ex) + { + TestContext.WriteLine($"Exception thrown:\n{ex}"); + return; + } + catch (Exception ex) when (!(ex is AssertionException)) + { + TestContext.WriteLine($"Exception thrown:\n{ex}"); + Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}."); + } + + // no exception thrown + Assert.Fail(message ?? "Didn't throw an exception."); + } + } +} diff --git a/src/StardewModdingAPI.Tests/packages.config b/src/StardewModdingAPI.Tests/packages.config new file mode 100644 index 00000000..6f04e625 --- /dev/null +++ b/src/StardewModdingAPI.Tests/packages.config @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Castle.Core" version="4.1.1" targetFramework="net45" /> + <package id="Moq" version="4.7.99" targetFramework="net45" /> + <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net45" /> + <package id="NUnit" version="3.7.1" targetFramework="net45" /> +</packages>
\ No newline at end of file diff --git a/src/StardewModdingAPI.sln b/src/StardewModdingAPI.sln new file mode 100644 index 00000000..a2e0ec44 --- /dev/null +++ b/src/StardewModdingAPI.sln @@ -0,0 +1,86 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 15 +VisualStudioVersion = 15.0.26730.15 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TrainerMod", "TrainerMod\TrainerMod.csproj", "{28480467-1A48-46A7-99F8-236D95225359}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI", "StardewModdingAPI\StardewModdingAPI.csproj", "{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "metadata", "metadata", "{86C452BE-D2D8-45B4-B63F-E329EB06CEDA}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + ..\.gitattributes = ..\.gitattributes + ..\.gitignore = ..\.gitignore + common.targets = common.targets + ..\CONTRIBUTING.md = ..\CONTRIBUTING.md + GlobalAssemblyInfo.cs = GlobalAssemblyInfo.cs + ..\LICENSE = ..\LICENSE + prepare-install-package.targets = prepare-install-package.targets + ..\README.md = ..\README.md + ..\release-notes.md = ..\release-notes.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Installer", "StardewModdingAPI.Installer\StardewModdingAPI.Installer.csproj", "{443DDF81-6AAF-420A-A610-3459F37E5575}" + ProjectSection(ProjectDependencies) = postProject + {28480467-1A48-46A7-99F8-236D95225359} = {28480467-1A48-46A7-99F8-236D95225359} + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F} = {F1A573B0-F436-472C-AE29-0B91EA6B9F8F} + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.Tests", "StardewModdingAPI.Tests\StardewModdingAPI.Tests.csproj", "{36CCB19E-92EB-48C7-9615-98EEFD45109B}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Debug|Mixed Platforms = Debug|Mixed Platforms + Debug|x86 = Debug|x86 + Release|Any CPU = Release|Any CPU + Release|Mixed Platforms = Release|Mixed Platforms + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {28480467-1A48-46A7-99F8-236D95225359}.Debug|Any CPU.ActiveCfg = Debug|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Debug|x86.ActiveCfg = Debug|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Debug|x86.Build.0 = Debug|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Release|Any CPU.ActiveCfg = Release|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Release|Mixed Platforms.Build.0 = Release|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Release|x86.ActiveCfg = Release|x86 + {28480467-1A48-46A7-99F8-236D95225359}.Release|x86.Build.0 = Release|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Any CPU.ActiveCfg = Debug|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|x86.ActiveCfg = Debug|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Debug|x86.Build.0 = Debug|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Any CPU.ActiveCfg = Release|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|Mixed Platforms.Build.0 = Release|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|x86.ActiveCfg = Release|x86 + {F1A573B0-F436-472C-AE29-0B91EA6B9F8F}.Release|x86.Build.0 = Release|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Any CPU.ActiveCfg = Debug|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|x86.ActiveCfg = Debug|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Debug|x86.Build.0 = Debug|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Any CPU.ActiveCfg = Release|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|Mixed Platforms.Build.0 = Release|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.ActiveCfg = Release|x86 + {443DDF81-6AAF-420A-A610-3459F37E5575}.Release|x86.Build.0 = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Any CPU.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|Mixed Platforms.Build.0 = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.ActiveCfg = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Debug|x86.Build.0 = Debug|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Any CPU.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|Mixed Platforms.Build.0 = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.ActiveCfg = Release|x86 + {36CCB19E-92EB-48C7-9615-98EEFD45109B}.Release|x86.Build.0 = Release|x86 + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/src/StardewModdingAPI.sln.DotSettings b/src/StardewModdingAPI.sln.DotSettings new file mode 100644 index 00000000..d16ef684 --- /dev/null +++ b/src/StardewModdingAPI.sln.DotSettings @@ -0,0 +1,19 @@ +<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=InheritdocConsiderUsage/@EntryIndexedValue">DO_NOT_SHOW</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=MemberCanBeMadeStatic_002ELocal/@EntryIndexedValue">DO_NOT_SHOW</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantNameQualifier/@EntryIndexedValue">HINT</s:String> + <s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantTypeArgumentsOfMethod/@EntryIndexedValue">HINT</s:String> + <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/StaticQualifier/STATIC_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> + <s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/ThisQualifier/INSTANCE_MEMBERS_QUALIFY_MEMBERS/@EntryValue">Field, Property, Event, Method</s:String> + <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/LINE_FEED_AT_FILE_END/@EntryValue">True</s:Boolean> + <s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/WRAP_LINES/@EntryValue">False</s:Boolean> + <s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForBuiltInTypes/@EntryValue">UseVarWhenEvident</s:String> + <s:String x:Key="/Default/CodeStyle/CSharpVarKeywordUsage/ForSimpleTypes/@EntryValue">UseExplicitType</s:String> + <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ID/@EntryIndexedValue">ID</s:String> + <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateInstanceFields/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></s:String> + <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticFields/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></s:String> + <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/PredefinedNamingRules/=PrivateStaticReadonly/@EntryIndexedValue"><Policy Inspect="True" Prefix="" Suffix="" Style="AaBb"><ExtraRule Prefix="_" Suffix="" Style="aaBb" /></Policy></s:String> + <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EAddAccessorOwnerDeclarationBracesMigration/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean> +</wpf:ResourceDictionary>
\ No newline at end of file diff --git a/src/StardewModdingAPI/App.config b/src/StardewModdingAPI/App.config new file mode 100644 index 00000000..27cdf0f7 --- /dev/null +++ b/src/StardewModdingAPI/App.config @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<configuration> + <startup> + <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5"/> + </startup> + <runtime> + <loadFromRemoteSources enabled="true"/> + </runtime> +</configuration> diff --git a/src/StardewModdingAPI/Command.cs b/src/StardewModdingAPI/Command.cs new file mode 100644 index 00000000..76c85287 --- /dev/null +++ b/src/StardewModdingAPI/Command.cs @@ -0,0 +1,159 @@ +#if SMAPI_1_x +using System; +using System.Collections.Generic; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI +{ + /// <summary>A command that can be submitted through the SMAPI console to interact with SMAPI.</summary> + [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ConsoleCommands))] + public class Command + { + /********* + ** Properties + *********/ + /// <summary>The commands registered with SMAPI.</summary> + private static readonly IDictionary<string, Command> LegacyCommands = new Dictionary<string, Command>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>Manages console commands.</summary> + private static CommandManager CommandManager; + + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + /// <summary>Tracks the installed mods.</summary> + private static ModRegistry ModRegistry; + + + /********* + ** Accessors + *********/ + /// <summary>The event raised when this command is submitted through the console.</summary> + public event EventHandler<EventArgsCommand> CommandFired; + + /**** + ** Command + ****/ + /// <summary>The name of the command.</summary> + public string CommandName; + + /// <summary>A human-readable description of what the command does.</summary> + public string CommandDesc; + + /// <summary>A human-readable list of accepted arguments.</summary> + public string[] CommandArgs; + + /// <summary>The actual submitted argument values.</summary> + public string[] CalledArgs; + + + /********* + ** Public methods + *********/ + /**** + ** Command + ****/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="commandManager">Manages console commands.</param> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + internal static void Shim(CommandManager commandManager, DeprecationManager deprecationManager, ModRegistry modRegistry) + { + Command.CommandManager = commandManager; + Command.DeprecationManager = deprecationManager; + Command.ModRegistry = modRegistry; + } + + /// <summary>Construct an instance.</summary> + /// <param name="name">The name of the command.</param> + /// <param name="description">A human-readable description of what the command does.</param> + /// <param name="args">A human-readable list of accepted arguments.</param> + public Command(string name, string description, string[] args = null) + { + this.CommandName = name; + this.CommandDesc = description; + if (args == null) + args = new string[0]; + this.CommandArgs = args; + } + + /// <summary>Trigger this command.</summary> + public void Fire() + { + if (this.CommandFired == null) + throw new InvalidOperationException($"Can't run command '{this.CommandName}' because it has no registered handler."); + this.CommandFired.Invoke(this, new EventArgsCommand(this)); + } + + + /**** + ** SMAPI + ****/ + /// <summary>Parse a command string and invoke it if valid.</summary> + /// <param name="input">The command to run, including the command name and any arguments.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public static void CallCommand(string input, IMonitor monitor) + { + Command.DeprecationManager.Warn("Command.CallCommand", "1.9", DeprecationLevel.PendingRemoval); + Command.CommandManager.Trigger(input); + } + + /// <summary>Register a command with SMAPI.</summary> + /// <param name="name">The name of the command.</param> + /// <param name="description">A human-readable description of what the command does.</param> + /// <param name="args">A human-readable list of accepted arguments.</param> + public static Command RegisterCommand(string name, string description, string[] args = null) + { + name = name?.Trim().ToLower(); + + // raise deprecation warning + Command.DeprecationManager.Warn("Command.RegisterCommand", "1.9", DeprecationLevel.PendingRemoval); + + // validate + if (Command.LegacyCommands.ContainsKey(name)) + throw new InvalidOperationException($"The '{name}' command is already registered!"); + + // add command + string modName = Command.ModRegistry.GetModFromStack() ?? "<unknown mod>"; + string documentation = args?.Length > 0 + ? $"{description} - {string.Join(", ", args)}" + : description; + Command.CommandManager.Add(modName, name, documentation, Command.Fire); + + // add legacy command + Command command = new Command(name, description, args); + Command.LegacyCommands.Add(name, command); + return command; + } + + /// <summary>Find a command with the given name.</summary> + /// <param name="name">The command name to find.</param> + public static Command FindCommand(string name) + { + Command.DeprecationManager.Warn("Command.FindCommand", "1.9", DeprecationLevel.PendingRemoval); + if (name == null) + return null; + + Command command; + Command.LegacyCommands.TryGetValue(name.Trim(), out command); + return command; + } + + + /********* + ** Private methods + *********/ + /// <summary>Trigger this command.</summary> + /// <param name="name">The command name.</param> + /// <param name="args">The command arguments.</param> + private static void Fire(string name, string[] args) + { + Command command; + if (!Command.LegacyCommands.TryGetValue(name, out command)) + throw new InvalidOperationException($"Can't run command '{name}' because there's no such legacy command."); + command.Fire(); + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Config.cs b/src/StardewModdingAPI/Config.cs new file mode 100644 index 00000000..734c04e8 --- /dev/null +++ b/src/StardewModdingAPI/Config.cs @@ -0,0 +1,188 @@ +#if SMAPI_1_x +using System; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI +{ + /// <summary>A dynamic configuration class for a mod.</summary> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public abstract class Config + { + /********* + ** Properties + *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the configuration file.</summary> + [JsonIgnore] + public virtual string ConfigLocation { get; protected internal set; } + + /// <summary>The directory path containing the configuration file.</summary> + [JsonIgnore] + public virtual string ConfigDir => Path.GetDirectoryName(this.ConfigLocation); + + + /********* + ** Public methods + *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + Config.DeprecationManager = deprecationManager; + } + + /// <summary>Construct an instance of the config class.</summary> + /// <typeparam name="T">The config class type.</typeparam> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public virtual Config Instance<T>() where T : Config => Activator.CreateInstance<T>(); + + /// <summary>Load the config from the JSON file, saving it to disk if needed.</summary> + /// <typeparam name="T">The config class type.</typeparam> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public virtual T LoadConfig<T>() where T : Config + { + // validate + if (string.IsNullOrEmpty(this.ConfigLocation)) + { + Log.Error("A config tried to load without specifying a location on the disk."); + return null; + } + + // read or generate config + T returnValue; + if (!File.Exists(this.ConfigLocation)) + { + T config = this.GenerateDefaultConfig<T>(); + config.ConfigLocation = this.ConfigLocation; + returnValue = config; + } + else + { + try + { + T config = JsonConvert.DeserializeObject<T>(File.ReadAllText(this.ConfigLocation)); + config.ConfigLocation = this.ConfigLocation; + returnValue = config.UpdateConfig<T>(); + } + catch (Exception ex) + { + Log.Error($"Invalid JSON ({this.GetType().Name}): {this.ConfigLocation} \n{ex}"); + return this.GenerateDefaultConfig<T>(); + } + } + + returnValue.WriteConfig(); + return returnValue; + } + + /// <summary>Get the default config values.</summary> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public virtual T GenerateDefaultConfig<T>() where T : Config + { + return null; + } + + /// <summary>Get the current configuration with missing values defaulted.</summary> + /// <typeparam name="T">The config class type.</typeparam> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public virtual T UpdateConfig<T>() where T : Config + { + try + { + // get default + user config + JObject defaultConfig = JObject.FromObject(this.Instance<T>().GenerateDefaultConfig<T>()); + JObject currentConfig = JObject.FromObject(this); + defaultConfig.Merge(currentConfig, new JsonMergeSettings { MergeArrayHandling = MergeArrayHandling.Replace }); + + // cast json object to config + T config = defaultConfig.ToObject<T>(); + + // update location + config.ConfigLocation = this.ConfigLocation; + + return config; + } + catch (Exception ex) + { + Log.Error($"An error occured when updating a config: {ex}"); + return this as T; + } + } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + protected Config() + { + Config.DeprecationManager.Warn("the Config class", "1.0", DeprecationLevel.PendingRemoval); + Config.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0"); // typically used to construct config, avoid redundant warnings + } + } + + /// <summary>Provides extension methods for <see cref="Config"/> classes.</summary> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public static class ConfigExtensions + { + /// <summary>Initialise the configuration. That includes loading, saving, and merging the config file and in memory at a default state. This method should not be used to reload or to resave a config. NOTE: You MUST set your config EQUAL to the return of this method!</summary> + /// <typeparam name="T">The config class type.</typeparam> + /// <param name="baseConfig">The base configuration to initialise.</param> + /// <param name="configLocation">The base configuration file path.</param> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public static T InitializeConfig<T>(this T baseConfig, string configLocation) where T : Config + { + if (baseConfig == null) + baseConfig = Activator.CreateInstance<T>(); + + if (string.IsNullOrEmpty(configLocation)) + { + Log.Error("A config tried to initialize without specifying a location on the disk."); + return null; + } + + baseConfig.ConfigLocation = configLocation; + return baseConfig.LoadConfig<T>(); + } + + /// <summary>Writes the configuration to the JSON file.</summary> + /// <typeparam name="T">The config class type.</typeparam> + /// <param name="baseConfig">The base configuration to initialise.</param> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public static void WriteConfig<T>(this T baseConfig) where T : Config + { + if (string.IsNullOrEmpty(baseConfig?.ConfigLocation) || string.IsNullOrEmpty(baseConfig.ConfigDir)) + { + Log.Error("A config attempted to save when it itself or it's location were null."); + return; + } + + string json = JsonConvert.SerializeObject(baseConfig, Formatting.Indented); + if (!Directory.Exists(baseConfig.ConfigDir)) + Directory.CreateDirectory(baseConfig.ConfigDir); + + if (!File.Exists(baseConfig.ConfigLocation) || !File.ReadAllText(baseConfig.ConfigLocation).SequenceEqual(json)) + File.WriteAllText(baseConfig.ConfigLocation, json); + } + + /// <summary>Rereads the JSON file and merges its values with a default config. NOTE: You MUST set your config EQUAL to the return of this method!</summary> + /// <typeparam name="T">The config class type.</typeparam> + /// <param name="baseConfig">The base configuration to initialise.</param> + [Obsolete("This base class is obsolete since SMAPI 1.0. See the latest project README for details.")] + public static T ReloadConfig<T>(this T baseConfig) where T : Config + { + return baseConfig.LoadConfig<T>(); + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Constants.cs b/src/StardewModdingAPI/Constants.cs new file mode 100644 index 00000000..07647d2d --- /dev/null +++ b/src/StardewModdingAPI/Constants.cs @@ -0,0 +1,165 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.ModLoading; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Contains SMAPI's constants and assumptions.</summary> + public static class Constants + { + /********* + ** Properties + *********/ + /// <summary>The directory path containing the current save's data (if a save is loaded).</summary> + private static string RawSavePath => Context.IsSaveLoaded ? Path.Combine(Constants.SavesPath, Constants.GetSaveFolderName()) : null; + + /// <summary>Whether the directory containing the current save's data exists on disk.</summary> + private static bool SavePathReady => Context.IsSaveLoaded && Directory.Exists(Constants.RawSavePath); + + + /********* + ** Accessors + *********/ + /**** + ** Public + ****/ + /// <summary>SMAPI's current semantic version.</summary> + public static ISemanticVersion ApiVersion { get; } = +#if SMAPI_1_x + new SemanticVersion(1, 15, 4); +#else + new SemanticVersion(2, 0, 0, $"alpha-{DateTime.UtcNow:yyyyMMddHHmm}"); +#endif + + /// <summary>The minimum supported version of Stardew Valley.</summary> + 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; + + /// <summary>The path to the game folder.</summary> + public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// <summary>The directory path containing Stardew Valley's app data.</summary> + public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); + + /// <summary>The directory path in which error logs should be stored.</summary> + public static string LogDir { get; } = Path.Combine(Constants.DataPath, "ErrorLogs"); + + /// <summary>The directory path where all saves are stored.</summary> + public static string SavesPath { get; } = Path.Combine(Constants.DataPath, "Saves"); + + /// <summary>The directory name containing the current save's data (if a save is loaded and the directory exists).</summary> + public static string SaveFolderName => Context.IsSaveLoaded ? Constants.GetSaveFolderName() : ""; + + /// <summary>The directory path containing the current save's data (if a save is loaded and the directory exists).</summary> + public static string CurrentSavePath => Constants.SavePathReady ? Path.Combine(Constants.SavesPath, Constants.GetSaveFolderName()) : ""; + + /**** + ** Internal + ****/ + /// <summary>The GitHub repository to check for updates.</summary> + internal const string GitHubRepository = "Pathoschild/SMAPI"; + + /// <summary>The file path for the SMAPI configuration file.</summary> + internal static string ApiConfigPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.config.json"); + + /// <summary>The file path to the log where the latest output should be saved.</summary> + internal static string DefaultLogPath => Path.Combine(Constants.LogDir, "SMAPI-latest.txt"); + + /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary> + internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt"); + + /// <summary>The file path which stores a fatal crash message for the next run.</summary> + internal static string FatalCrashMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.crash.marker"); + + /// <summary>The full path to the folder containing mods.</summary> + internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); + + /// <summary>The game's current semantic version.</summary> + internal static ISemanticVersion GameVersion { get; } = new GameVersion(Constants.GetGameVersion()); + + /// <summary>The target game platform.</summary> + internal static Platform TargetPlatform { get; } = +#if SMAPI_FOR_WINDOWS + Platform.Windows; +#else + Platform.Mono; +#endif + + + /********* + ** Internal methods + *********/ + /// <summary>Get metadata for mapping assemblies to the current platform.</summary> + /// <param name="targetPlatform">The target game platform.</param> + internal static PlatformAssemblyMap GetAssemblyMap(Platform targetPlatform) + { + // get assembly changes needed for platform + string[] removeAssemblyReferences; + Assembly[] targetAssemblies; + switch (targetPlatform) + { + case Platform.Mono: + removeAssemblyReferences = new[] + { + "Stardew Valley", + "Microsoft.Xna.Framework", + "Microsoft.Xna.Framework.Game", + "Microsoft.Xna.Framework.Graphics" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly + }; + break; + + case Platform.Windows: + removeAssemblyReferences = new[] + { + "StardewValley", + "MonoGame.Framework" + }; + targetAssemblies = new[] + { + typeof(StardewValley.Game1).Assembly, + typeof(Microsoft.Xna.Framework.Vector2).Assembly, + typeof(Microsoft.Xna.Framework.Game).Assembly, + typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch).Assembly + }; + break; + + default: + throw new InvalidOperationException($"Unknown target platform '{targetPlatform}'."); + } + + return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences, targetAssemblies); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the name of a save directory for the current player.</summary> + private static string GetSaveFolderName() + { + string prefix = new string(Game1.player.name.Where(char.IsLetterOrDigit).ToArray()); + return $"{prefix}_{Game1.uniqueIDForThisGame}"; + } + + /// <summary>Get the game's current version string.</summary> + private static string GetGameVersion() + { + // we need reflection because it's a constant, so SMAPI's references to it are inlined at compile-time + FieldInfo field = typeof(Game1).GetField(nameof(Game1.version), BindingFlags.Public | BindingFlags.Static); + if (field == null) + throw new InvalidOperationException($"The {nameof(Game1)}.{nameof(Game1.version)} field could not be found."); + return (string)field.GetValue(null); + } + } +} diff --git a/src/StardewModdingAPI/ContentSource.cs b/src/StardewModdingAPI/ContentSource.cs new file mode 100644 index 00000000..35c8bc21 --- /dev/null +++ b/src/StardewModdingAPI/ContentSource.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Specifies a source containing content that can be loaded.</summary> + public enum ContentSource + { + /// <summary>Assets in the game's content manager (i.e. XNBs in the game's content folder).</summary> + GameContent, + + /// <summary>XNB files in the current mod's folder.</summary> + ModFolder + } +} diff --git a/src/StardewModdingAPI/Context.cs b/src/StardewModdingAPI/Context.cs new file mode 100644 index 00000000..119e14c8 --- /dev/null +++ b/src/StardewModdingAPI/Context.cs @@ -0,0 +1,37 @@ +using StardewModdingAPI.Events; +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI +{ + /// <summary>Provides information about the current game state.</summary> + public static class Context + { + /********* + ** Accessors + *********/ + /**** + ** Public + ****/ + /// <summary>Whether the player has loaded a save and the world has finished initialising.</summary> + public static bool IsWorldReady { get; internal set; } + + /// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary> + public static bool IsPlayerFree => Context.IsWorldReady && Game1.activeClickableMenu == null && !Game1.dialogueUp && !Game1.eventUp; + + /// <summary>Whether <see cref="IsPlayerFree"/> is true and the player is free to move (e.g. not using a tool).</summary> + public static bool CanPlayerMove => Context.IsPlayerFree && Game1.player.CanMove; + + /// <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 + ****/ + /// <summary>Whether a player save has been loaded.</summary> + internal static bool IsSaveLoaded => Game1.hasLoadedGame && !string.IsNullOrEmpty(Game1.player.name); + + /// <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 + } +} diff --git a/src/StardewModdingAPI/Events/ChangeType.cs b/src/StardewModdingAPI/Events/ChangeType.cs new file mode 100644 index 00000000..4b207f08 --- /dev/null +++ b/src/StardewModdingAPI/Events/ChangeType.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Events +{ + /// <summary>Indicates how an inventory item changed.</summary> + public enum ChangeType + { + /// <summary>The entire stack was removed.</summary> + Removed, + + /// <summary>The entire stack was added.</summary> + Added, + + /// <summary>The stack size changed.</summary> + StackChange + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/ContentEvents.cs b/src/StardewModdingAPI/Events/ContentEvents.cs new file mode 100644 index 00000000..4b4e2ad0 --- /dev/null +++ b/src/StardewModdingAPI/Events/ContentEvents.cs @@ -0,0 +1,29 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the game loads content.</summary> + public static class ContentEvents + { + + /********* + ** Events + *********/ + /// <summary>Raised after the content language changes.</summary> + public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise an <see cref="AfterLocaleChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="oldLocale">The previous locale.</param> + /// <param name="newLocale">The current locale.</param> + internal static void InvokeAfterLocaleChanged(IMonitor monitor, string oldLocale, string newLocale) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ContentEvents)}.{nameof(ContentEvents.AfterLocaleChanged)}", ContentEvents.AfterLocaleChanged?.GetInvocationList(), null, new EventArgsValueChanged<string>(oldLocale, newLocale)); + } + } +} diff --git a/src/StardewModdingAPI/Events/ControlEvents.cs b/src/StardewModdingAPI/Events/ControlEvents.cs new file mode 100644 index 00000000..80d0f547 --- /dev/null +++ b/src/StardewModdingAPI/Events/ControlEvents.cs @@ -0,0 +1,112 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player uses a controller, keyboard, or mouse.</summary> + public static class ControlEvents + { + /********* + ** Events + *********/ + /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> + public static event EventHandler<EventArgsKeyboardStateChanged> KeyboardChanged; + + /// <summary>Raised when the player presses a keyboard key.</summary> + public static event EventHandler<EventArgsKeyPressed> KeyPressed; + + /// <summary>Raised when the player releases a keyboard key.</summary> + public static event EventHandler<EventArgsKeyPressed> KeyReleased; + + /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> + public static event EventHandler<EventArgsMouseStateChanged> MouseChanged; + + /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> + public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed; + + /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> + public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased; + + /// <summary>The player pressed a controller trigger button.</summary> + public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed; + + /// <summary>The player released a controller trigger button.</summary> + public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="KeyboardChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorState">The previous keyboard state.</param> + /// <param name="newState">The current keyboard state.</param> + internal static void InvokeKeyboardChanged(IMonitor monitor, KeyboardState priorState, KeyboardState newState) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyboardChanged)}", ControlEvents.KeyboardChanged?.GetInvocationList(), null, new EventArgsKeyboardStateChanged(priorState, newState)); + } + + /// <summary>Raise a <see cref="MouseChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorState">The previous mouse state.</param> + /// <param name="newState">The current mouse state.</param> + /// <param name="priorPosition">The previous mouse position on the screen adjusted for the zoom level.</param> + /// <param name="newPosition">The current mouse position on the screen adjusted for the zoom level.</param> + internal static void InvokeMouseChanged(IMonitor monitor, MouseState priorState, MouseState newState, Point priorPosition, Point newPosition) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.MouseChanged)}", ControlEvents.MouseChanged?.GetInvocationList(), null, new EventArgsMouseStateChanged(priorState, newState, priorPosition, newPosition)); + } + + /// <summary>Raise a <see cref="KeyPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="key">The keyboard button that was pressed.</param> + internal static void InvokeKeyPressed(IMonitor monitor, Keys key) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyPressed)}", ControlEvents.KeyPressed?.GetInvocationList(), null, new EventArgsKeyPressed(key)); + } + + /// <summary>Raise a <see cref="KeyReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="key">The keyboard button that was released.</param> + internal static void InvokeKeyReleased(IMonitor monitor, Keys key) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.KeyReleased)}", ControlEvents.KeyReleased?.GetInvocationList(), null, new EventArgsKeyPressed(key)); + } + + /// <summary>Raise a <see cref="ControllerButtonPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The controller button that was pressed.</param> + internal static void InvokeButtonPressed(IMonitor monitor, Buttons button) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerButtonPressed)}", ControlEvents.ControllerButtonPressed?.GetInvocationList(), null, new EventArgsControllerButtonPressed(PlayerIndex.One, button)); + } + + /// <summary>Raise a <see cref="ControllerButtonReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The controller button that was released.</param> + internal static void InvokeButtonReleased(IMonitor monitor, Buttons button) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerButtonReleased)}", ControlEvents.ControllerButtonReleased?.GetInvocationList(), null, new EventArgsControllerButtonReleased(PlayerIndex.One, button)); + } + + /// <summary>Raise a <see cref="ControllerTriggerPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + internal static void InvokeTriggerPressed(IMonitor monitor, Buttons button, float value) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerTriggerPressed)}", ControlEvents.ControllerTriggerPressed?.GetInvocationList(), null, new EventArgsControllerTriggerPressed(PlayerIndex.One, button, value)); + } + + /// <summary>Raise a <see cref="ControllerTriggerReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + internal static void InvokeTriggerReleased(IMonitor monitor, Buttons button, float value) + { + monitor.SafelyRaiseGenericEvent($"{nameof(ControlEvents)}.{nameof(ControlEvents.ControllerTriggerReleased)}", ControlEvents.ControllerTriggerReleased?.GetInvocationList(), null, new EventArgsControllerTriggerReleased(PlayerIndex.One, button, value)); + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs b/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs new file mode 100644 index 00000000..2a2aa163 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsClickableMenuChanged.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MenuEvents.MenuChanged"/> event.</summary> + public class EventArgsClickableMenuChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous menu.</summary> + public IClickableMenu NewMenu { get; } + + /// <summary>The current menu.</summary> + public IClickableMenu PriorMenu { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + public EventArgsClickableMenuChanged(IClickableMenu priorMenu, IClickableMenu newMenu) + { + this.NewMenu = newMenu; + this.PriorMenu = priorMenu; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs b/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs new file mode 100644 index 00000000..5e6585f0 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsClickableMenuClosed.cs @@ -0,0 +1,26 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MenuEvents.MenuClosed"/> event.</summary> + public class EventArgsClickableMenuClosed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The menu that was closed.</summary> + public IClickableMenu PriorMenu { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorMenu">The menu that was closed.</param> + public EventArgsClickableMenuClosed(IClickableMenu priorMenu) + { + this.PriorMenu = priorMenu; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsCommand.cs b/src/StardewModdingAPI/Events/EventArgsCommand.cs new file mode 100644 index 00000000..35370139 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsCommand.cs @@ -0,0 +1,28 @@ +#if SMAPI_1_x +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="StardewModdingAPI.Command.CommandFired"/> event.</summary> + [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ConsoleCommands))] + public class EventArgsCommand : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The triggered command.</summary> + public Command Command { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="command">The triggered command.</param> + public EventArgsCommand(Command command) + { + this.Command = command; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs b/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs new file mode 100644 index 00000000..3243b80b --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerButtonPressed.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonPressed"/> event.</summary> + public class EventArgsControllerButtonPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonPressed { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the button.</param> + /// <param name="button">The controller button that was pressed.</param> + public EventArgsControllerButtonPressed(PlayerIndex playerIndex, Buttons button) + { + this.PlayerIndex = playerIndex; + this.ButtonPressed = button; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs b/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs new file mode 100644 index 00000000..e05a080b --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerButtonReleased.cs @@ -0,0 +1,32 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonReleased"/> event.</summary> + public class EventArgsControllerButtonReleased : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonReleased { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the button.</param> + /// <param name="button">The controller button that was released.</param> + public EventArgsControllerButtonReleased(PlayerIndex playerIndex, Buttons button) + { + this.PlayerIndex = playerIndex; + this.ButtonReleased = button; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs b/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs new file mode 100644 index 00000000..a2087733 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerTriggerPressed.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerPressed"/> event.</summary> + public class EventArgsControllerTriggerPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was pressed.</summary> + public Buttons ButtonPressed { get; } + + /// <summary>The current trigger value.</summary> + public float Value { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the trigger button.</param> + /// <param name="button">The trigger button that was pressed.</param> + /// <param name="value">The current trigger value.</param> + public EventArgsControllerTriggerPressed(PlayerIndex playerIndex, Buttons button, float value) + { + this.PlayerIndex = playerIndex; + this.ButtonPressed = button; + this.Value = value; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs b/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs new file mode 100644 index 00000000..d2eecbec --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsControllerTriggerReleased.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerReleased"/> event.</summary> + public class EventArgsControllerTriggerReleased : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who pressed the button.</summary> + public PlayerIndex PlayerIndex { get; } + + /// <summary>The controller button that was released.</summary> + public Buttons ButtonReleased { get; } + + /// <summary>The current trigger value.</summary> + public float Value { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerIndex">The player who pressed the trigger button.</param> + /// <param name="button">The trigger button that was released.</param> + /// <param name="value">The current trigger value.</param> + public EventArgsControllerTriggerReleased(PlayerIndex playerIndex, Buttons button, float value) + { + this.PlayerIndex = playerIndex; + this.ButtonReleased = button; + this.Value = value; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs b/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs new file mode 100644 index 00000000..25d3ebf3 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsCurrentLocationChanged.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.CurrentLocationChanged"/> event.</summary> + public class EventArgsCurrentLocationChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player's current location.</summary> + public GameLocation NewLocation { get; } + + /// <summary>The player's previous location.</summary> + public GameLocation PriorLocation { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + public EventArgsCurrentLocationChanged(GameLocation priorLocation, GameLocation newLocation) + { + this.NewLocation = newLocation; + this.PriorLocation = priorLocation; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs b/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs new file mode 100644 index 00000000..4c359939 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsFarmerChanged.cs @@ -0,0 +1,33 @@ +#if SMAPI_1_x +using System; +using SFarmer = StardewValley.Farmer; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.FarmerChanged"/> event.</summary> + public class EventArgsFarmerChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous player character.</summary> + public SFarmer NewFarmer { get; } + + /// <summary>The new player character.</summary> + public SFarmer PriorFarmer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorFarmer">The previous player character.</param> + /// <param name="newFarmer">The new player character.</param> + public EventArgsFarmerChanged(SFarmer priorFarmer, SFarmer newFarmer) + { + this.PriorFarmer = priorFarmer; + this.NewFarmer = newFarmer; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs b/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs new file mode 100644 index 00000000..fb8c821e --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsGameLocationsChanged.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.LocationsChanged"/> event.</summary> + public class EventArgsGameLocationsChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The current list of game locations.</summary> + public List<GameLocation> NewLocations { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="newLocations">The current list of game locations.</param> + public EventArgsGameLocationsChanged(List<GameLocation> newLocations) + { + this.NewLocations = newLocations; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsInput.cs b/src/StardewModdingAPI/Events/EventArgsInput.cs new file mode 100644 index 00000000..31368555 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsInput.cs @@ -0,0 +1,126 @@ +#if !SMAPI_1_x +using System; +using System.Linq; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments when a button is pressed or released.</summary> + public class EventArgsInput : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The button on the controller, keyboard, or mouse.</summary> + public SButton Button { get; } + + /// <summary>The current cursor position.</summary> + public ICursorPosition Cursor { get; set; } + + /// <summary>Whether the input is considered a 'click' by the game for enabling action.</summary> + public bool IsClick { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + public EventArgsInput(SButton button, ICursorPosition cursor, bool isClick) + { + this.Button = button; + this.Cursor = cursor; + this.IsClick = isClick; + } + + /// <summary>Prevent the game from handling the vurrent button press. This doesn't prevent other mods from receiving the event.</summary> + public void SuppressButton() + { + this.SuppressButton(this.Button); + } + + /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary> + /// <param name="button">The button to suppress.</param> + public void SuppressButton(SButton button) + { + // keyboard + if (this.Button.TryGetKeyboard(out Keys key)) + Game1.oldKBState = new KeyboardState(Game1.oldKBState.GetPressedKeys().Except(new[] { key }).ToArray()); + + // controller + else if (this.Button.TryGetController(out Buttons controllerButton)) + { + var newState = GamePad.GetState(PlayerIndex.One); + var thumbsticks = Game1.oldPadState.ThumbSticks; + var triggers = Game1.oldPadState.Triggers; + var buttons = Game1.oldPadState.Buttons; + var dpad = Game1.oldPadState.DPad; + + switch (controllerButton) + { + // d-pad + case Buttons.DPadDown: + dpad = new GamePadDPad(dpad.Up, newState.DPad.Down, dpad.Left, dpad.Right); + break; + case Buttons.DPadLeft: + dpad = new GamePadDPad(dpad.Up, dpad.Down, newState.DPad.Left, dpad.Right); + break; + case Buttons.DPadRight: + dpad = new GamePadDPad(dpad.Up, dpad.Down, dpad.Left, newState.DPad.Right); + break; + case Buttons.DPadUp: + dpad = new GamePadDPad(newState.DPad.Up, dpad.Down, dpad.Left, dpad.Right); + break; + + // trigger + case Buttons.LeftTrigger: + triggers = new GamePadTriggers(newState.Triggers.Left, triggers.Right); + break; + case Buttons.RightTrigger: + triggers = new GamePadTriggers(triggers.Left, newState.Triggers.Right); + break; + + // thumbstick + case Buttons.LeftThumbstickDown: + case Buttons.LeftThumbstickLeft: + case Buttons.LeftThumbstickRight: + case Buttons.LeftThumbstickUp: + thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Left, thumbsticks.Right); + break; + case Buttons.RightThumbstickDown: + case Buttons.RightThumbstickLeft: + case Buttons.RightThumbstickRight: + case Buttons.RightThumbstickUp: + thumbsticks = new GamePadThumbSticks(newState.ThumbSticks.Right, thumbsticks.Left); + break; + + // buttons + default: + var mask = + (buttons.A == ButtonState.Pressed ? Buttons.A : 0) + | (buttons.B == ButtonState.Pressed ? Buttons.B : 0) + | (buttons.Back == ButtonState.Pressed ? Buttons.Back : 0) + | (buttons.BigButton == ButtonState.Pressed ? Buttons.BigButton : 0) + | (buttons.LeftShoulder == ButtonState.Pressed ? Buttons.LeftShoulder : 0) + | (buttons.LeftStick == ButtonState.Pressed ? Buttons.LeftStick : 0) + | (buttons.RightShoulder == ButtonState.Pressed ? Buttons.RightShoulder : 0) + | (buttons.RightStick == ButtonState.Pressed ? Buttons.RightStick : 0) + | (buttons.Start == ButtonState.Pressed ? Buttons.Start : 0) + | (buttons.X == ButtonState.Pressed ? Buttons.X : 0) + | (buttons.Y == ButtonState.Pressed ? Buttons.Y : 0); + mask = mask ^ controllerButton; + buttons = new GamePadButtons(mask); + break; + } + + Game1.oldPadState = new GamePadState(thumbsticks, triggers, buttons, dpad); + } + } + } +} +#endif diff --git a/src/StardewModdingAPI/Events/EventArgsIntChanged.cs b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs new file mode 100644 index 00000000..0c742d12 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsIntChanged.cs @@ -0,0 +1,29 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an integer field that changed value.</summary> + public class EventArgsIntChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous value.</summary> + public int PriorInt { get; } + + /// <summary>The current value.</summary> + public int NewInt { get; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorInt">The previous value.</param> + /// <param name="newInt">The current value.</param> + public EventArgsIntChanged(int priorInt, int newInt) + { + this.PriorInt = priorInt; + this.NewInt = newInt; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs new file mode 100644 index 00000000..1ee02842 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsInventoryChanged.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.InventoryChanged"/> event.</summary> + public class EventArgsInventoryChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player's inventory.</summary> + public List<Item> Inventory { get; } + + /// <summary>The added items.</summary> + public List<ItemStackChange> Added { get; } + + /// <summary>The removed items.</summary> + public List<ItemStackChange> Removed { get; } + + /// <summary>The items whose stack sizes changed.</summary> + public List<ItemStackChange> QuantityChanged { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="inventory">The player's inventory.</param> + /// <param name="changedItems">The inventory changes.</param> + public EventArgsInventoryChanged(List<Item> inventory, List<ItemStackChange> changedItems) + { + this.Inventory = inventory; + this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList(); + this.Removed = changedItems.Where(n => n.ChangeType == ChangeType.Removed).ToList(); + this.QuantityChanged = changedItems.Where(n => n.ChangeType == ChangeType.StackChange).ToList(); + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs b/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs new file mode 100644 index 00000000..d9d81e10 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsKeyPressed.cs @@ -0,0 +1,26 @@ +using System; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> + public class EventArgsKeyPressed : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The keyboard button that was pressed.</summary> + public Keys KeyPressed { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="key">The keyboard button that was pressed.</param> + public EventArgsKeyPressed(Keys key) + { + this.KeyPressed = key; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs b/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs new file mode 100644 index 00000000..14e397ce --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsKeyboardStateChanged.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> + public class EventArgsKeyboardStateChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous keyboard state.</summary> + public KeyboardState NewState { get; } + + /// <summary>The current keyboard state.</summary> + public KeyboardState PriorState { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorState">The previous keyboard state.</param> + /// <param name="newState">The current keyboard state.</param> + public EventArgsKeyboardStateChanged(KeyboardState priorState, KeyboardState newState) + { + this.PriorState = priorState; + this.NewState = newState; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsLevelUp.cs b/src/StardewModdingAPI/Events/EventArgsLevelUp.cs new file mode 100644 index 00000000..fe6696d4 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsLevelUp.cs @@ -0,0 +1,52 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.LeveledUp"/> event.</summary> + public class EventArgsLevelUp : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player skill that leveled up.</summary> + public LevelType Type { get; } + + /// <summary>The new skill level.</summary> + public int NewLevel { get; } + + /// <summary>The player skill types.</summary> + public enum LevelType + { + /// <summary>The combat skill.</summary> + Combat, + + /// <summary>The farming skill.</summary> + Farming, + + /// <summary>The fishing skill.</summary> + Fishing, + + /// <summary>The foraging skill.</summary> + Foraging, + + /// <summary>The mining skill.</summary> + Mining, + + /// <summary>The luck skill.</summary> + Luck + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The player skill that leveled up.</param> + /// <param name="newLevel">The new skill level.</param> + public EventArgsLevelUp(LevelType type, int newLevel) + { + this.Type = type; + this.NewLevel = newLevel; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsLoadedGameChanged.cs b/src/StardewModdingAPI/Events/EventArgsLoadedGameChanged.cs new file mode 100644 index 00000000..688b4b3d --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsLoadedGameChanged.cs @@ -0,0 +1,27 @@ +#if SMAPI_1_x +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="PlayerEvents.LoadedGame"/> event.</summary> + public class EventArgsLoadedGameChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>Whether the save has been loaded. This is always true.</summary> + public bool LoadedGame { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="loaded">Whether the save has been loaded. This is always true.</param> + public EventArgsLoadedGameChanged(bool loaded) + { + this.LoadedGame = loaded; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs b/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs new file mode 100644 index 00000000..058999e9 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsLocationObjectsChanged.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Xna.Framework; +using StardewValley; +using Object = StardewValley.Object; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="LocationEvents.LocationObjectsChanged"/> event.</summary> + public class EventArgsLocationObjectsChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The current list of objects in the current location.</summary> + public SerializableDictionary<Vector2, Object> NewObjects { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="newObjects">The current list of objects in the current location.</param> + public EventArgsLocationObjectsChanged(SerializableDictionary<Vector2, Object> newObjects) + { + this.NewObjects = newObjects; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs b/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs new file mode 100644 index 00000000..c82fed35 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsMineLevelChanged.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="MineEvents.MineLevelChanged"/> event.</summary> + public class EventArgsMineLevelChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous mine level.</summary> + public int PreviousMineLevel { get; } + + /// <summary>The current mine level.</summary> + public int CurrentMineLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="previousMineLevel">The previous mine level.</param> + /// <param name="currentMineLevel">The current mine level.</param> + public EventArgsMineLevelChanged(int previousMineLevel, int currentMineLevel) + { + this.PreviousMineLevel = previousMineLevel; + this.CurrentMineLevel = currentMineLevel; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs b/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs new file mode 100644 index 00000000..57298164 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsMouseStateChanged.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Input; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="ControlEvents.MouseChanged"/> event.</summary> + public class EventArgsMouseStateChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous mouse state.</summary> + public MouseState PriorState { get; } + + /// <summary>The current mouse state.</summary> + public MouseState NewState { get; } + + /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> + public Point PriorPosition { get; } + + /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> + public Point NewPosition { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorState">The previous mouse state.</param> + /// <param name="newState">The current mouse state.</param> + /// <param name="priorPosition">The previous mouse position on the screen adjusted for the zoom level.</param> + /// <param name="newPosition">The current mouse position on the screen adjusted for the zoom level.</param> + public EventArgsMouseStateChanged(MouseState priorState, MouseState newState, Point priorPosition, Point newPosition) + { + this.PriorState = priorState; + this.NewState = newState; + this.PriorPosition = priorPosition; + this.NewPosition = newPosition; + } + } +} diff --git a/src/StardewModdingAPI/Events/EventArgsNewDay.cs b/src/StardewModdingAPI/Events/EventArgsNewDay.cs new file mode 100644 index 00000000..b8cbe9e3 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsNewDay.cs @@ -0,0 +1,37 @@ +#if SMAPI_1_x +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="TimeEvents.OnNewDay"/> event.</summary> + public class EventArgsNewDay : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous day value.</summary> + public int PreviousDay { get; } + + /// <summary>The current day value.</summary> + public int CurrentDay { get; } + + /// <summary>Whether the game just started the transition (<c>true</c>) or finished it (<c>false</c>).</summary> + public bool IsNewDay { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorDay">The previous day value.</param> + /// <param name="newDay">The current day value.</param> + /// <param name="isTransitioning">Whether the game just started the transition (<c>true</c>) or finished it (<c>false</c>).</param> + public EventArgsNewDay(int priorDay, int newDay, bool isTransitioning) + { + this.PreviousDay = priorDay; + this.CurrentDay = newDay; + this.IsNewDay = isTransitioning; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/EventArgsStringChanged.cs b/src/StardewModdingAPI/Events/EventArgsStringChanged.cs new file mode 100644 index 00000000..f580a2ce --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsStringChanged.cs @@ -0,0 +1,31 @@ +#if SMAPI_1_x +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a string field that changed value.</summary> + public class EventArgsStringChanged : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous value.</summary> + public string NewString { get; } + + /// <summary>The current value.</summary> + public string PriorString { get; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorString">The previous value.</param> + /// <param name="newString">The current value.</param> + public EventArgsStringChanged(string priorString, string newString) + { + this.NewString = newString; + this.PriorString = priorString; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/EventArgsValueChanged.cs b/src/StardewModdingAPI/Events/EventArgsValueChanged.cs new file mode 100644 index 00000000..1d25af49 --- /dev/null +++ b/src/StardewModdingAPI/Events/EventArgsValueChanged.cs @@ -0,0 +1,31 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a field that changed value.</summary> + /// <typeparam name="T">The value type.</typeparam> + public class EventArgsValueChanged<T> : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous value.</summary> + public T PriorValue { get; } + + /// <summary>The current value.</summary> + public T NewValue { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="priorValue">The previous value.</param> + /// <param name="newValue">The current value.</param> + public EventArgsValueChanged(T priorValue, T newValue) + { + this.PriorValue = priorValue; + this.NewValue = newValue; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/GameEvents.cs b/src/StardewModdingAPI/Events/GameEvents.cs new file mode 100644 index 00000000..5610e67a --- /dev/null +++ b/src/StardewModdingAPI/Events/GameEvents.cs @@ -0,0 +1,216 @@ +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> + public static class GameEvents + { + /********* + ** Properties + *********/ +#if SMAPI_1_x + /// <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; +#endif + + + /********* + ** 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> + internal static event EventHandler InitializeInternal; + + /// <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> + internal static event EventHandler GameLoadedInternal; + +#if SMAPI_1_x + /// <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 + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.Initialize)}", "1.10", DeprecationLevel.PendingRemoval); + 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 + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}", "1.10", DeprecationLevel.PendingRemoval); + 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 + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}", "1.12", DeprecationLevel.PendingRemoval); + 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 + { + add + { + GameEvents.DeprecationManager.Warn($"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}", "1.12", DeprecationLevel.PendingRemoval); + GameEvents._FirstUpdateTick += value; + } + remove => GameEvents._FirstUpdateTick -= value; + } +#endif + + /// <summary>Raised when the game updates its state (≈60 times per second).</summary> + public static event EventHandler UpdateTick; + + /// <summary>Raised every other tick (≈30 times per second).</summary> + public static event EventHandler SecondUpdateTick; + + /// <summary>Raised every fourth tick (≈15 times per second).</summary> + public static event EventHandler FourthUpdateTick; + + /// <summary>Raised every eighth tick (≈8 times per second).</summary> + public static event EventHandler EighthUpdateTick; + + /// <summary>Raised every 15th tick (≈4 times per second).</summary> + public static event EventHandler QuarterSecondTick; + + /// <summary>Raised every 30th tick (≈twice per second).</summary> + public static event EventHandler HalfSecondTick; + + /// <summary>Raised every 60th tick (≈once per second).</summary> + public static event EventHandler OneSecondTick; + + + /********* + ** Internal methods + *********/ +#if SMAPI_1_x + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + GameEvents.DeprecationManager = deprecationManager; + } +#endif + + /// <summary>Raise an <see cref="InitializeInternal"/> event.</summary> + /// <param name="monitor">Encapsulates logging and monitoring.</param> + internal static void InvokeInitialize(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.InitializeInternal)}", GameEvents.InitializeInternal?.GetInvocationList()); +#if SMAPI_1_x + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.Initialize)}", GameEvents._Initialize?.GetInvocationList()); +#endif + } + +#if SMAPI_1_x + /// <summary>Raise a <see cref="LoadContent"/> event.</summary> + /// <param name="monitor">Encapsulates logging and monitoring.</param> + internal static void InvokeLoadContent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.LoadContent)}", GameEvents._LoadContent?.GetInvocationList()); + } +#endif + + /// <summary>Raise a <see cref="GameLoadedInternal"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeGameLoaded(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoadedInternal)}", GameEvents.GameLoadedInternal?.GetInvocationList()); +#if SMAPI_1_x + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.GameLoaded)}", GameEvents._GameLoaded?.GetInvocationList()); +#endif + } + +#if SMAPI_1_x + /// <summary>Raise a <see cref="FirstUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeFirstUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.FirstUpdateTick)}", GameEvents._FirstUpdateTick?.GetInvocationList()); + } +#endif + + /// <summary>Raise an <see cref="UpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates logging and monitoring.</param> + internal static void InvokeUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.UpdateTick)}", GameEvents.UpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="SecondUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeSecondUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.SecondUpdateTick)}", GameEvents.SecondUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="FourthUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeFourthUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.FourthUpdateTick)}", GameEvents.FourthUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="EighthUpdateTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeEighthUpdateTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.EighthUpdateTick)}", GameEvents.EighthUpdateTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="QuarterSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeQuarterSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.QuarterSecondTick)}", GameEvents.QuarterSecondTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="HalfSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeHalfSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.HalfSecondTick)}", GameEvents.HalfSecondTick?.GetInvocationList()); + } + + /// <summary>Raise a <see cref="OneSecondTick"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOneSecondTick(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GameEvents)}.{nameof(GameEvents.OneSecondTick)}", GameEvents.OneSecondTick?.GetInvocationList()); + } + } +} diff --git a/src/StardewModdingAPI/Events/GraphicsEvents.cs b/src/StardewModdingAPI/Events/GraphicsEvents.cs new file mode 100644 index 00000000..fff51bed --- /dev/null +++ b/src/StardewModdingAPI/Events/GraphicsEvents.cs @@ -0,0 +1,116 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised during the game's draw loop, when the game is rendering content to the window.</summary> + public static class GraphicsEvents + { + /********* + ** Events + *********/ + /**** + ** Generic events + ****/ + /// <summary>Raised after the game window is resized.</summary> + public static event EventHandler Resize; + + /**** + ** Main render events + ****/ + /// <summary>Raised before drawing the world to the screen.</summary> + public static event EventHandler OnPreRenderEvent; + + /// <summary>Raised after drawing the world to the screen.</summary> + public static event EventHandler OnPostRenderEvent; + + /**** + ** HUD events + ****/ + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> + public static event EventHandler OnPreRenderHudEvent; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> + public static event EventHandler OnPostRenderHudEvent; + + /**** + ** GUI events + ****/ + /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> + public static event EventHandler OnPreRenderGuiEvent; + + /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> + public static event EventHandler OnPostRenderGuiEvent; + + + /********* + ** Internal methods + *********/ + /**** + ** Generic events + ****/ + /// <summary>Raise a <see cref="Resize"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeResize(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.Resize)}", GraphicsEvents.Resize?.GetInvocationList()); + } + + /**** + ** Main render events + ****/ + /// <summary>Raise an <see cref="OnPreRenderEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderEvent)}", GraphicsEvents.OnPreRenderEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderEvent)}", GraphicsEvents.OnPostRenderEvent?.GetInvocationList()); + } + + /// <summary>Get whether there are any post-render event listeners.</summary> + internal static bool HasPostRenderListeners() + { + return GraphicsEvents.OnPostRenderEvent != null; + } + + /**** + ** GUI events + ****/ + /// <summary>Raise an <see cref="OnPreRenderGuiEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderGuiEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderGuiEvent)}", GraphicsEvents.OnPreRenderGuiEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderGuiEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderGuiEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderGuiEvent)}", GraphicsEvents.OnPostRenderGuiEvent?.GetInvocationList()); + } + + /**** + ** HUD events + ****/ + /// <summary>Raise an <see cref="OnPreRenderHudEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPreRenderHudEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPreRenderHudEvent)}", GraphicsEvents.OnPreRenderHudEvent?.GetInvocationList()); + } + + /// <summary>Raise an <see cref="OnPostRenderHudEvent"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeOnPostRenderHudEvent(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(GraphicsEvents)}.{nameof(GraphicsEvents.OnPostRenderHudEvent)}", GraphicsEvents.OnPostRenderHudEvent?.GetInvocationList()); + } + } +} diff --git a/src/StardewModdingAPI/Events/InputEvents.cs b/src/StardewModdingAPI/Events/InputEvents.cs new file mode 100644 index 00000000..b99b49e0 --- /dev/null +++ b/src/StardewModdingAPI/Events/InputEvents.cs @@ -0,0 +1,45 @@ +#if !SMAPI_1_x +using System; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player uses a controller, keyboard, or mouse button.</summary> + public static class InputEvents + { + /********* + ** Events + *********/ + /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> + public static event EventHandler<EventArgsInput> ButtonPressed; + + /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> + public static event EventHandler<EventArgsInput> ButtonReleased; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="ButtonPressed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + internal static void InvokeButtonPressed(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + { + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonPressed)}", InputEvents.ButtonPressed?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + } + + /// <summary>Raise a <see cref="ButtonReleased"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="button">The button on the controller, keyboard, or mouse.</param> + /// <param name="cursor">The cursor position.</param> + /// <param name="isClick">Whether the input is considered a 'click' by the game for enabling action.</param> + internal static void InvokeButtonReleased(IMonitor monitor, SButton button, ICursorPosition cursor, bool isClick) + { + monitor.SafelyRaiseGenericEvent($"{nameof(InputEvents)}.{nameof(InputEvents.ButtonReleased)}", InputEvents.ButtonReleased?.GetInvocationList(), null, new EventArgsInput(button, cursor, isClick)); + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/ItemStackChange.cs b/src/StardewModdingAPI/Events/ItemStackChange.cs new file mode 100644 index 00000000..f9ae6df6 --- /dev/null +++ b/src/StardewModdingAPI/Events/ItemStackChange.cs @@ -0,0 +1,20 @@ +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Represents an inventory slot that changed.</summary> + public class ItemStackChange + { + /********* + ** Accessors + *********/ + /// <summary>The item in the slot.</summary> + public Item Item { get; set; } + + /// <summary>The amount by which the item's stack size changed.</summary> + public int StackChange { get; set; } + + /// <summary>How the inventory slot changed.</summary> + public ChangeType ChangeType { get; set; } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Events/LocationEvents.cs b/src/StardewModdingAPI/Events/LocationEvents.cs new file mode 100644 index 00000000..b834bc1c --- /dev/null +++ b/src/StardewModdingAPI/Events/LocationEvents.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework; +using StardewValley; +using Object = StardewValley.Object; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player transitions between game locations, a location is added or removed, or the objects in the current location change.</summary> + public static class LocationEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the player warps to a new location.</summary> + public static event EventHandler<EventArgsCurrentLocationChanged> CurrentLocationChanged; + + /// <summary>Raised after a game location is added or removed.</summary> + public static event EventHandler<EventArgsGameLocationsChanged> LocationsChanged; + + /// <summary>Raised after the list of objects in the current location changes (e.g. an object is added or removed).</summary> + public static event EventHandler<EventArgsLocationObjectsChanged> LocationObjectsChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="CurrentLocationChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + internal static void InvokeCurrentLocationChanged(IMonitor monitor, GameLocation priorLocation, GameLocation newLocation) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.CurrentLocationChanged)}", LocationEvents.CurrentLocationChanged?.GetInvocationList(), null, new EventArgsCurrentLocationChanged(priorLocation, newLocation)); + } + + /// <summary>Raise a <see cref="LocationsChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="newLocations">The current list of game locations.</param> + internal static void InvokeLocationsChanged(IMonitor monitor, List<GameLocation> newLocations) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.LocationsChanged)}", LocationEvents.LocationsChanged?.GetInvocationList(), null, new EventArgsGameLocationsChanged(newLocations)); + } + + /// <summary>Raise a <see cref="LocationObjectsChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="newObjects">The current list of objects in the current location.</param> + internal static void InvokeOnNewLocationObject(IMonitor monitor, SerializableDictionary<Vector2, Object> newObjects) + { + monitor.SafelyRaiseGenericEvent($"{nameof(LocationEvents)}.{nameof(LocationEvents.LocationObjectsChanged)}", LocationEvents.LocationObjectsChanged?.GetInvocationList(), null, new EventArgsLocationObjectsChanged(newObjects)); + } + } +} diff --git a/src/StardewModdingAPI/Events/MenuEvents.cs b/src/StardewModdingAPI/Events/MenuEvents.cs new file mode 100644 index 00000000..bd8d897e --- /dev/null +++ b/src/StardewModdingAPI/Events/MenuEvents.cs @@ -0,0 +1,40 @@ +using System; +using StardewModdingAPI.Framework; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when a game menu is opened or closed (including internal menus like the title screen).</summary> + public static class MenuEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> + public static event EventHandler<EventArgsClickableMenuChanged> MenuChanged; + + /// <summary>Raised after a game menu is closed.</summary> + public static event EventHandler<EventArgsClickableMenuClosed> MenuClosed; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="MenuChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + internal static void InvokeMenuChanged(IMonitor monitor, IClickableMenu priorMenu, IClickableMenu newMenu) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MenuEvents)}.{nameof(MenuEvents.MenuChanged)}", MenuEvents.MenuChanged?.GetInvocationList(), null, new EventArgsClickableMenuChanged(priorMenu, newMenu)); + } + + /// <summary>Raise a <see cref="MenuClosed"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorMenu">The menu that was closed.</param> + internal static void InvokeMenuClosed(IMonitor monitor, IClickableMenu priorMenu) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MenuEvents)}.{nameof(MenuEvents.MenuClosed)}", MenuEvents.MenuClosed?.GetInvocationList(), null, new EventArgsClickableMenuClosed(priorMenu)); + } + } +} diff --git a/src/StardewModdingAPI/Events/MineEvents.cs b/src/StardewModdingAPI/Events/MineEvents.cs new file mode 100644 index 00000000..9cf7edac --- /dev/null +++ b/src/StardewModdingAPI/Events/MineEvents.cs @@ -0,0 +1,28 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when something happens in the mines.</summary> + public static class MineEvents + { + /********* + ** Events + *********/ + /// <summary>Raised after the player warps to a new level of the mine.</summary> + public static event EventHandler<EventArgsMineLevelChanged> MineLevelChanged; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="MineLevelChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="previousMineLevel">The previous mine level.</param> + /// <param name="currentMineLevel">The current mine level.</param> + internal static void InvokeMineLevelChanged(IMonitor monitor, int previousMineLevel, int currentMineLevel) + { + monitor.SafelyRaiseGenericEvent($"{nameof(MineEvents)}.{nameof(MineEvents.MineLevelChanged)}", MineEvents.MineLevelChanged?.GetInvocationList(), null, new EventArgsMineLevelChanged(previousMineLevel, currentMineLevel)); + } + } +} diff --git a/src/StardewModdingAPI/Events/PlayerEvents.cs b/src/StardewModdingAPI/Events/PlayerEvents.cs new file mode 100644 index 00000000..72826330 --- /dev/null +++ b/src/StardewModdingAPI/Events/PlayerEvents.cs @@ -0,0 +1,115 @@ +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> + public static class PlayerEvents + { + /********* + ** Properties + *********/ +#if SMAPI_1_x + /// <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; +#endif + + + /********* + ** Events + *********/ +#if SMAPI_1_x + /// <summary>Raised after the player loads a saved game.</summary> + [Obsolete("Use " + nameof(SaveEvents) + "." + nameof(SaveEvents.AfterLoad) + " instead")] + public static event EventHandler<EventArgsLoadedGameChanged> LoadedGame + { + add + { + PlayerEvents.DeprecationManager.Warn($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}", "1.6", DeprecationLevel.PendingRemoval); + 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 + { + add + { + PlayerEvents.DeprecationManager.Warn($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}", "1.6", DeprecationLevel.PendingRemoval); + PlayerEvents._FarmerChanged += value; + } + remove => PlayerEvents._FarmerChanged -= value; + } +#endif + + /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> + public static event EventHandler<EventArgsInventoryChanged> InventoryChanged; + + /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> + public static event EventHandler<EventArgsLevelUp> LeveledUp; + + + /********* + ** Internal methods + *********/ +#if SMAPI_1_x + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + PlayerEvents.DeprecationManager = deprecationManager; + } + + /// <summary>Raise a <see cref="LoadedGame"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="loaded">Whether the save has been loaded. This is always true.</param> + internal static void InvokeLoadedGame(IMonitor monitor, EventArgsLoadedGameChanged loaded) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LoadedGame)}", PlayerEvents._LoadedGame?.GetInvocationList(), null, loaded); + } + + /// <summary>Raise a <see cref="FarmerChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorFarmer">The previous player character.</param> + /// <param name="newFarmer">The new player character.</param> + internal static void InvokeFarmerChanged(IMonitor monitor, SFarmer priorFarmer, SFarmer newFarmer) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.FarmerChanged)}", PlayerEvents._FarmerChanged?.GetInvocationList(), null, new EventArgsFarmerChanged(priorFarmer, newFarmer)); + } +#endif + + /// <summary>Raise an <see cref="InventoryChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="inventory">The player's inventory.</param> + /// <param name="changedItems">The inventory changes.</param> + internal static void InvokeInventoryChanged(IMonitor monitor, List<Item> inventory, IEnumerable<ItemStackChange> changedItems) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.InventoryChanged)}", PlayerEvents.InventoryChanged?.GetInvocationList(), null, new EventArgsInventoryChanged(inventory, changedItems.ToList())); + } + + /// <summary>Rase a <see cref="LeveledUp"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="type">The player skill that leveled up.</param> + /// <param name="newLevel">The new skill level.</param> + internal static void InvokeLeveledUp(IMonitor monitor, EventArgsLevelUp.LevelType type, int newLevel) + { + monitor.SafelyRaiseGenericEvent($"{nameof(PlayerEvents)}.{nameof(PlayerEvents.LeveledUp)}", PlayerEvents.LeveledUp?.GetInvocationList(), null, new EventArgsLevelUp(type, newLevel)); + } + } +} diff --git a/src/StardewModdingAPI/Events/SaveEvents.cs b/src/StardewModdingAPI/Events/SaveEvents.cs new file mode 100644 index 00000000..50e6d729 --- /dev/null +++ b/src/StardewModdingAPI/Events/SaveEvents.cs @@ -0,0 +1,56 @@ +using System; +using StardewModdingAPI.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised before and after the player saves/loads the game.</summary> + public static class SaveEvents + { + /********* + ** Events + *********/ + /// <summary>Raised before the game begins writes data to the save file.</summary> + public static event EventHandler BeforeSave; + + /// <summary>Raised after the game finishes writing data to the save file.</summary> + public static event EventHandler AfterSave; + + /// <summary>Raised after the player loads a save slot.</summary> + public static event EventHandler AfterLoad; + + /// <summary>Raised after the game returns to the title screen.</summary> + public static event EventHandler AfterReturnToTitle; + + + /********* + ** Internal methods + *********/ + /// <summary>Raise a <see cref="BeforeSave"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeBeforeSave(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.BeforeSave)}", SaveEvents.BeforeSave?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterSave"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterSave(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterSave)}", SaveEvents.AfterSave?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterLoad"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterLoad(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterLoad)}", SaveEvents.AfterLoad?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="AfterReturnToTitle"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterReturnToTitle(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(SaveEvents)}.{nameof(SaveEvents.AfterReturnToTitle)}", SaveEvents.AfterReturnToTitle?.GetInvocationList(), null, EventArgs.Empty); + } + } +} diff --git a/src/StardewModdingAPI/Events/TimeEvents.cs b/src/StardewModdingAPI/Events/TimeEvents.cs new file mode 100644 index 00000000..d5ab9fb7 --- /dev/null +++ b/src/StardewModdingAPI/Events/TimeEvents.cs @@ -0,0 +1,163 @@ +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> + public static class TimeEvents + { + /********* + ** Properties + *********/ +#if SMAPI_1_x + /// <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; +#endif + + + /********* + ** Events + *********/ + /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + public static event EventHandler AfterDayStarted; + + /// <summary>Raised after the in-game clock changes.</summary> + public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged; + +#if SMAPI_1_x + /// <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> + [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.PendingRemoval); + TimeEvents._DayOfMonthChanged += value; + } + remove => TimeEvents._DayOfMonthChanged -= value; + } + + /// <summary>Raised after the year value changes.</summary> + [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.PendingRemoval); + TimeEvents._YearOfGameChanged += value; + } + remove => TimeEvents._YearOfGameChanged -= value; + } + + /// <summary>Raised after the season value changes.</summary> + [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.PendingRemoval); + 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 + { + add + { + TimeEvents.DeprecationManager.Warn($"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}", "1.6", DeprecationLevel.PendingRemoval); + TimeEvents._OnNewDay += value; + } + remove => TimeEvents._OnNewDay -= value; + } +#endif + + + /********* + ** Internal methods + *********/ +#if SMAPI_1_x + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + TimeEvents.DeprecationManager = deprecationManager; + } +#endif + + /// <summary>Raise an <see cref="AfterDayStarted"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + internal static void InvokeAfterDayStarted(IMonitor monitor) + { + monitor.SafelyRaisePlainEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.AfterDayStarted)}", TimeEvents.AfterDayStarted?.GetInvocationList(), null, EventArgs.Empty); + } + + /// <summary>Raise a <see cref="TimeOfDayChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorTime">The previous time in military time format (e.g. 6:00pm is 1800).</param> + /// <param name="newTime">The current time in military time format (e.g. 6:10pm is 1810).</param> + internal static void InvokeTimeOfDayChanged(IMonitor monitor, int priorTime, int newTime) + { + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.TimeOfDayChanged)}", TimeEvents.TimeOfDayChanged?.GetInvocationList(), null, new EventArgsIntChanged(priorTime, newTime)); + } + +#if SMAPI_1_x + /// <summary>Raise a <see cref="DayOfMonthChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorDay">The previous day value.</param> + /// <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)); + } + + /// <summary>Raise a <see cref="YearOfGameChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorYear">The previous year value.</param> + /// <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)); + } + + /// <summary>Raise a <see cref="SeasonOfYearChanged"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorSeason">The previous season name.</param> + /// <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)); + } + + /// <summary>Raise a <see cref="OnNewDay"/> event.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="priorDay">The previous day value.</param> + /// <param name="newDay">The current day value.</param> + /// <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) + { + monitor.SafelyRaiseGenericEvent($"{nameof(TimeEvents)}.{nameof(TimeEvents.OnNewDay)}", TimeEvents._OnNewDay?.GetInvocationList(), null, new EventArgsNewDay(priorDay, newDay, isTransitioning)); + } +#endif + } +} diff --git a/src/StardewModdingAPI/Framework/Command.cs b/src/StardewModdingAPI/Framework/Command.cs new file mode 100644 index 00000000..943e018d --- /dev/null +++ b/src/StardewModdingAPI/Framework/Command.cs @@ -0,0 +1,40 @@ +using System; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A command that can be submitted through the SMAPI console to interact with SMAPI.</summary> + internal class Command + { + /********* + ** Accessor + *********/ + /// <summary>The friendly name for the mod that registered the command.</summary> + public string ModName { get; } + + /// <summary>The command name, which the user must type to trigger it.</summary> + public string Name { get; } + + /// <summary>The human-readable documentation shown when the player runs the built-in 'help' command.</summary> + public string Documentation { get; } + + /// <summary>The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</summary> + public Action<string, string[]> Callback { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The friendly name for the mod that registered the command.</param> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + public Command(string modName, string name, string documentation, Action<string, string[]> callback) + { + this.ModName = modName; + this.Name = name; + this.Documentation = documentation; + this.Callback = callback; + } + } +} diff --git a/src/StardewModdingAPI/Framework/CommandManager.cs b/src/StardewModdingAPI/Framework/CommandManager.cs new file mode 100644 index 00000000..9af3d27a --- /dev/null +++ b/src/StardewModdingAPI/Framework/CommandManager.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages console commands.</summary> + internal class CommandManager + { + /********* + ** Properties + *********/ + /// <summary>The commands registered with SMAPI.</summary> + private readonly IDictionary<string, Command> Commands = new Dictionary<string, Command>(StringComparer.InvariantCultureIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Add a console command.</summary> + /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <param name="allowNullCallback">Whether to allow a null <paramref name="callback"/> argument; this should only used for backwards compatibility.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + public void Add(string modName, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) + { + name = this.GetNormalisedName(name); + + // validate format + if (string.IsNullOrWhiteSpace(name)) + throw new ArgumentNullException(nameof(name), "Can't register a command with no name."); + if (name.Any(char.IsWhiteSpace)) + throw new FormatException($"Can't register the '{name}' command because the name can't contain whitespace."); + if (callback == null && !allowNullCallback) + throw new ArgumentNullException(nameof(callback), $"Can't register the '{name}' command because without a callback."); + + // ensure uniqueness + if (this.Commands.ContainsKey(name)) + throw new ArgumentException(nameof(callback), $"Can't register the '{name}' command because there's already a command with that name."); + + // add command + this.Commands.Add(name, new Command(modName, name, documentation, callback)); + } + + /// <summary>Get a command by its unique name.</summary> + /// <param name="name">The command name.</param> + /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> + public Command Get(string name) + { + name = this.GetNormalisedName(name); + Command command; + this.Commands.TryGetValue(name, out command); + return command; + } + + /// <summary>Get all registered commands.</summary> + public IEnumerable<Command> GetAll() + { + return this.Commands + .Values + .OrderBy(p => p.Name); + } + + /// <summary>Trigger a command.</summary> + /// <param name="input">The raw command input.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string input) + { + if (string.IsNullOrWhiteSpace(input)) + return false; + + string[] args = input.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + string name = args[0]; + args = args.Skip(1).ToArray(); + + return this.Trigger(name, args); + } + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string name, string[] arguments) + { + // get normalised name + name = this.GetNormalisedName(name); + if (name == null) + return false; + + // get command + Command command; + if (this.Commands.TryGetValue(name, out command)) + { + command.Callback.Invoke(name, arguments); + return true; + } + return false; + } + + /********* + ** Private methods + *********/ + /// <summary>Get a normalised command name.</summary> + /// <param name="name">The command name.</param> + private string GetNormalisedName(string name) + { + name = name?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(name) + ? name + : null; + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Content/AssetData.cs b/src/StardewModdingAPI/Framework/Content/AssetData.cs new file mode 100644 index 00000000..1ab9eebd --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetData.cs @@ -0,0 +1,44 @@ +using System; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Base implementation for a content helper which encapsulates access and changes to content being read from a data file.</summary> + /// <typeparam name="TValue">The interface value type.</typeparam> + internal class AssetData<TValue> : AssetInfo, IAssetData<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The content data being read.</summary> + public TValue Data { get; protected set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data.GetType(), getNormalisedPath) + { + this.Data = data; + } + + /// <summary>Replace the entire content value with the given value. This is generally not recommended, since it may break compatibility with other mods or different versions of the game.</summary> + /// <param name="value">The new content value.</param> + /// <exception cref="ArgumentNullException">The <paramref name="value"/> is null.</exception> + /// <exception cref="InvalidCastException">The <paramref name="value"/>'s type is not compatible with the loaded asset's type.</exception> + public void ReplaceWith(TValue value) + { + if (value == null) + throw new ArgumentNullException(nameof(value), "Can't set a loaded asset to a null value."); + if (!this.DataType.IsInstanceOfType(value)) + throw new InvalidCastException($"Can't replace loaded asset of type {this.GetFriendlyTypeName(this.DataType)} with value of type {this.GetFriendlyTypeName(value.GetType())}. The new type must be compatible to prevent game errors."); + + this.Data = value; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs new file mode 100644 index 00000000..e9b29b12 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForDictionary.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + internal class AssetDataForDictionary<TKey, TValue> : AssetData<IDictionary<TKey, TValue>>, IAssetDataForDictionary<TKey, TValue> + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">The entry value.</param> + public void Set(TKey key, TValue value) + { + this.Data[key] = value; + } + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">A callback which accepts the current value and returns the new value.</param> + public void Set(TKey key, Func<TValue, TValue> value) + { + this.Data[key] = value(this.Data[key]); + } + + /// <summary>Dynamically replace values in the dictionary.</summary> + /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> + public void Set(Func<TKey, TValue, TValue> replacer) + { + foreach (var pair in this.Data.ToArray()) + this.Data[pair.Key] = replacer(pair.Key, pair.Value); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs new file mode 100644 index 00000000..45c5588b --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForImage.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + internal class AssetDataForImage : AssetData<Texture2D>, IAssetDataForImage + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Overwrite part of the image.</summary> + /// <param name="source">The image to patch into the content.</param> + /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + public void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace) + { + // get texture + Texture2D target = this.Data; + + // get areas + sourceArea = sourceArea ?? new Rectangle(0, 0, source.Width, source.Height); + targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); + + // validate + if (source == null) + throw new ArgumentNullException(nameof(source), "Can't patch from a null source texture."); + if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) + throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); + if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height) + throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); + if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + throw new InvalidOperationException("The source and target areas must be the same size."); + + // get source data + int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; + Color[] sourceData = new Color[pixelCount]; + source.GetData(0, sourceArea, sourceData, 0, pixelCount); + + // merge data in overlay mode + if (patchMode == PatchMode.Overlay) + { + Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; + target.GetData(0, targetArea, newData, 0, newData.Length); + for (int i = 0; i < sourceData.Length; i++) + { + Color pixel = sourceData[i]; + if (pixel.A != 0) // not transparent + newData[i] = pixel; + } + sourceData = newData; + } + + // patch target texture + target.SetData(0, targetArea, sourceData, 0, pixelCount); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs new file mode 100644 index 00000000..f30003e4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetDataForObject.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>Encapsulates access and changes to content being read from a data file.</summary> + internal class AssetDataForObject : AssetData<object>, IAssetData + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalisedPath) + : base(locale, assetName, data, getNormalisedPath) { } + + /// <summary>Construct an instance.</summary> + /// <param name="info">The asset metadata.</param> + /// <param name="data">The content data being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalisedPath) + : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + + /// <summary>Get a helper to manipulate the data as a dictionary.</summary> + /// <typeparam name="TKey">The expected dictionary key.</typeparam> + /// <typeparam name="TValue">The expected dictionary balue.</typeparam> + /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() + { + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalisedPath); + } + + /// <summary>Get a helper to manipulate the data as an image.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + public IAssetDataForImage AsImage() + { + return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalisedPath); + } + + /// <summary>Get the data as a given type.</summary> + /// <typeparam name="TData">The expected data type.</typeparam> + /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> + public TData GetData<TData>() + { + if (!(this.Data is TData)) + throw new InvalidCastException($"The content data of type {this.Data.GetType().FullName} can't be converted to the requested {typeof(TData).FullName}."); + return (TData)this.Data; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Content/AssetInfo.cs b/src/StardewModdingAPI/Framework/Content/AssetInfo.cs new file mode 100644 index 00000000..d580dc06 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Content/AssetInfo.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.Content +{ + internal class AssetInfo : IAssetInfo + { + /********* + ** Properties + *********/ + /// <summary>Normalises an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalisedPath; + + + /********* + ** Accessors + *********/ + /// <summary>The content's locale code, if the content is localised.</summary> + public string Locale { get; } + + /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + public string AssetName { get; } + + /// <summary>The content data type.</summary> + public Type DataType { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The content's locale code, if the content is localised.</param> + /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="type">The content type being read.</param> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalisedPath) + { + this.Locale = locale; + this.AssetName = assetName; + this.DataType = type; + this.GetNormalisedPath = getNormalisedPath; + } + + /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + public bool AssetNameEquals(string path) + { + path = this.GetNormalisedPath(path); + return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get a human-readable type name.</summary> + /// <param name="type">The type to name.</param> + protected string GetFriendlyTypeName(Type type) + { + // dictionary + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Dictionary<,>)) + { + Type[] genericArgs = type.GetGenericArguments(); + return $"Dictionary<{this.GetFriendlyTypeName(genericArgs[0])}, {this.GetFriendlyTypeName(genericArgs[1])}>"; + } + + // texture + if (type == typeof(Texture2D)) + return type.Name; + + // native type + if (type == typeof(int)) + return "int"; + if (type == typeof(string)) + return "string"; + + // default + return type.FullName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ContentManagerShim.cs b/src/StardewModdingAPI/Framework/ContentManagerShim.cs new file mode 100644 index 00000000..d46f23a3 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ContentManagerShim.cs @@ -0,0 +1,50 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A minimal content manager which defers to SMAPI's main content manager.</summary> + internal class ContentManagerShim : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's underlying content manager.</summary> + private readonly SContentManager ContentManager; + + + /********* + ** Accessors + *********/ + /// <summary>The content manager's name for logs (if any).</summary> + public string Name { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentManager">SMAPI's underlying content manager.</param> + /// <param name="name">The content manager's name for logs (if any).</param> + public ContentManagerShim(SContentManager contentManager, string name) + : base(contentManager.ServiceProvider, contentManager.RootDirectory, contentManager.CurrentCulture, contentManager.LanguageCodeOverride) + { + this.ContentManager = contentManager; + this.Name = name; + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.ContentManager.LoadFor<T>(assetName, this); + } + + /// <summary>Dispose held resources.</summary> + /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> + protected override void Dispose(bool disposing) + { + this.ContentManager.DisposeFor(this); + } + } +} diff --git a/src/StardewModdingAPI/Framework/CursorPosition.cs b/src/StardewModdingAPI/Framework/CursorPosition.cs new file mode 100644 index 00000000..0fb2309b --- /dev/null +++ b/src/StardewModdingAPI/Framework/CursorPosition.cs @@ -0,0 +1,37 @@ +#if !SMAPI_1_x +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Defines a position on a given map at different reference points.</summary> + internal class CursorPosition : ICursorPosition + { + /********* + ** Accessors + *********/ + /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + public Vector2 ScreenPixels { get; } + + /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> + public Vector2 Tile { get; } + + /// <summary>The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than <see cref="Tile"/> if that's too far from the player.</summary> + public Vector2 GrabTile { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen.</param> + /// <param name="tile">The tile position relative to the top-left corner of the map.</param> + /// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param> + public CursorPosition(Vector2 screenPixels, Vector2 tile, Vector2 grabTile) + { + this.ScreenPixels = screenPixels; + this.Tile = tile; + this.GrabTile = grabTile; + } + } +} +#endif diff --git a/src/StardewModdingAPI/Framework/DeprecationLevel.cs b/src/StardewModdingAPI/Framework/DeprecationLevel.cs new file mode 100644 index 00000000..c0044053 --- /dev/null +++ b/src/StardewModdingAPI/Framework/DeprecationLevel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>Indicates how deprecated something is.</summary> + internal enum DeprecationLevel + { + /// <summary>It's deprecated but won't be removed soon. Mod authors have some time to update their mods. Deprecation warnings should be logged, but not written to the console.</summary> + Notice, + + /// <summary>Mods should no longer be using it. Deprecation messages should be debug entries in the console.</summary> + Info, + + /// <summary>The code will be removed soon. Deprecation messages should be warnings in the console.</summary> + PendingRemoval + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/DeprecationManager.cs b/src/StardewModdingAPI/Framework/DeprecationManager.cs new file mode 100644 index 00000000..43e82d74 --- /dev/null +++ b/src/StardewModdingAPI/Framework/DeprecationManager.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Manages deprecation warnings.</summary> + internal class DeprecationManager + { + /********* + ** Properties + *********/ + /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary> + private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + private readonly IMonitor Monitor; + + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) + { + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Log a deprecation warning.</summary> + /// <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 void Warn(string nounPhrase, string version, DeprecationLevel severity) + { + this.Warn(this.ModRegistry.GetModFromStack(), nounPhrase, version, severity); + } + + /// <summary>Log a deprecation warning.</summary> + /// <param name="source">The friendly mod name which used the deprecated code.</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 void Warn(string source, string nounPhrase, string version, DeprecationLevel severity) + { + // ignore if already warned + if (!this.MarkWarned(source ?? "<unknown>", nounPhrase, version)) + return; + + // show SMAPI 2.0 meta-warning + if(this.MarkWarned("SMAPI", "SMAPI 2.0 meta-warning", "2.0")) + this.Monitor.Log("Some mods may stop working in SMAPI 2.0 (but they'll work fine for now). Try updating mods with 'deprecated code' warnings; if that doesn't remove the warnings, let the mod authors know about this message or see http://stardewvalleywiki.com/Modding:SMAPI_2.0 for details.", LogLevel.Warn); + + // build message + string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase})."; + if (source == null) + message += $"{Environment.NewLine}{Environment.StackTrace}"; + + // log message + switch (severity) + { + case DeprecationLevel.Notice: + this.Monitor.Log(message, LogLevel.Trace); + break; + + case DeprecationLevel.Info: + this.Monitor.Log(message, LogLevel.Debug); + break; + + case DeprecationLevel.PendingRemoval: + this.Monitor.Log(message, LogLevel.Warn); + break; + + default: + throw new NotSupportedException($"Unknown deprecation level '{severity}'"); + } + } + + /// <summary>Mark a deprecation warning as already logged.</summary> + /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns> + public bool MarkWarned(string nounPhrase, string version) + { + return this.MarkWarned(this.ModRegistry.GetModFromStack(), nounPhrase, version); + } + + /// <summary>Mark a deprecation warning as already logged.</summary> + /// <param name="source">The friendly name of the assembly which used the deprecated code.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns> + public bool MarkWarned(string source, string nounPhrase, string version) + { + if (string.IsNullOrWhiteSpace(source)) + throw new InvalidOperationException("The deprecation source cannot be empty."); + + string key = $"{source}::{nounPhrase}::{version}"; + if (this.LoggedDeprecations.Contains(key)) + return false; + this.LoggedDeprecations.Add(key); + return true; + } + + /// <summary>Get whether a type implements the given virtual method.</summary> + /// <param name="subtype">The type to check.</param> + /// <param name="baseType">The base type which declares the virtual method.</param> + /// <param name="name">The method name.</param> + /// <param name="argumentTypes">The expected argument types.</param> + internal bool IsVirtualMethodImplemented(Type subtype, Type baseType, string name, Type[] argumentTypes) + { + MethodInfo method = subtype.GetMethod(nameof(Mod.Entry), argumentTypes); + return method.DeclaringType != baseType; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs new file mode 100644 index 00000000..ec9279f1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SAssemblyLoadFailedException.cs @@ -0,0 +1,16 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>An exception thrown when an assembly can't be loaded by SMAPI, with all the relevant details in the message.</summary> + internal class SAssemblyLoadFailedException : Exception + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + public SAssemblyLoadFailedException(string message) + : base(message) { } + } +} 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/Exceptions/SParseException.cs b/src/StardewModdingAPI/Framework/Exceptions/SParseException.cs new file mode 100644 index 00000000..f7133ee7 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Exceptions/SParseException.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Framework.Exceptions +{ + /// <summary>A format exception which provides a user-facing error message.</summary> + internal class SParseException : FormatException + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public SParseException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/GameVersion.cs b/src/StardewModdingAPI/Framework/GameVersion.cs new file mode 100644 index 00000000..48159f61 --- /dev/null +++ b/src/StardewModdingAPI/Framework/GameVersion.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework +{ + /// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary> + internal class GameVersion : SemanticVersion + { + /********* + ** Private methods + *********/ + /// <summary>A mapping of game to semantic versions.</summary> + private static readonly IDictionary<string, string> VersionMap = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) + { + ["1.01"] = "1.0.1", + ["1.02"] = "1.0.2", + ["1.03"] = "1.0.3", + ["1.04"] = "1.0.4", + ["1.05"] = "1.0.5", + ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. + ["1.051b"] = "1.0.6-prelease2", + ["1.06"] = "1.0.6", + ["1.07"] = "1.0.7", + ["1.07a"] = "1.0.8-prerelease1", + ["1.11"] = "1.1.1" + }; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="version">The game version string.</param> + public GameVersion(string version) + : base(GameVersion.GetSemanticVersionString(version)) { } + + /// <summary>Get a string representation of the version.</summary> + public override string ToString() + { + return GameVersion.GetGameVersionString(base.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Convert a game version string to a semantic version string.</summary> + /// <param name="gameVersion">The game version string.</param> + private static string GetSemanticVersionString(string gameVersion) + { + return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + ? semanticVersion + : gameVersion; + } + + /// <summary>Convert a game version string to a semantic version string.</summary> + /// <param name="gameVersion">The game version string.</param> + private static string GetGameVersionString(string gameVersion) + { + foreach (var mapping in GameVersion.VersionMap) + { + if (mapping.Value.Equals(gameVersion, StringComparison.InvariantCultureIgnoreCase)) + return mapping.Key; + } + return gameVersion; + } + } +} diff --git a/src/StardewModdingAPI/Framework/IModMetadata.cs b/src/StardewModdingAPI/Framework/IModMetadata.cs new file mode 100644 index 00000000..56ac25f1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/IModMetadata.cs @@ -0,0 +1,47 @@ +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModLoading; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Metadata for a mod.</summary> + internal interface IModMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + string DisplayName { get; } + + /// <summary>The mod's full directory path.</summary> + string DirectoryPath { get; } + + /// <summary>The mod manifest.</summary> + IManifest Manifest { get; } + + /// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> + ModCompatibility Compatibility { get; } + + /// <summary>The metadata resolution status.</summary> + ModMetadataStatus Status { get; } + + /// <summary>The reason the metadata is invalid, if any.</summary> + string Error { get; } + + /// <summary>The mod instance (if it was loaded).</summary> + IMod Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Set the mod status.</summary> + /// <param name="status">The metadata resolution status.</param> + /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <returns>Return the instance for chaining.</returns> + IModMetadata SetStatus(ModMetadataStatus status, string error = null); + + /// <summary>Set the mod instance.</summary> + /// <param name="mod">The mod instance to set.</param> + IModMetadata SetMod(IMod mod); + } +} diff --git a/src/StardewModdingAPI/Framework/InternalExtensions.cs b/src/StardewModdingAPI/Framework/InternalExtensions.cs new file mode 100644 index 00000000..3709e05d --- /dev/null +++ b/src/StardewModdingAPI/Framework/InternalExtensions.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides extension methods for SMAPI's internal use.</summary> + internal static class InternalExtensions + { + /**** + ** IMonitor + ****/ + /// <summary>Safely raise an <see cref="EventHandler"/> event, and intercept any exceptions thrown by its handlers.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="name">The event name for error messages.</param> + /// <param name="handlers">The event handlers.</param> + /// <param name="sender">The event sender.</param> + /// <param name="args">The event arguments (or <c>null</c> to pass <see cref="EventArgs.Empty"/>).</param> + public static void SafelyRaisePlainEvent(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender = null, EventArgs args = null) + { + if (handlers == null) + return; + + foreach (EventHandler handler in handlers.Cast<EventHandler>()) + { + // handle SMAPI exiting + if (monitor.IsExiting) + { + monitor.Log($"SMAPI shutting down: aborting {name} event.", LogLevel.Warn); + return; + } + + // raise event + try + { + handler.Invoke(sender, args ?? EventArgs.Empty); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// <summary>Safely raise an <see cref="EventHandler{TEventArgs}"/> event, and intercept any exceptions thrown by its handlers.</summary> + /// <typeparam name="TEventArgs">The event argument object type.</typeparam> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="name">The event name for error messages.</param> + /// <param name="handlers">The event handlers.</param> + /// <param name="sender">The event sender.</param> + /// <param name="args">The event arguments.</param> + public static void SafelyRaiseGenericEvent<TEventArgs>(this IMonitor monitor, string name, IEnumerable<Delegate> handlers, object sender, TEventArgs args) + { + if (handlers == null) + return; + + foreach (EventHandler<TEventArgs> handler in handlers.Cast<EventHandler<TEventArgs>>()) + { + try + { + handler.Invoke(sender, args); + } + catch (Exception ex) + { + monitor.Log($"A mod failed handling the {name} event:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + } + + /// <summary>Log a message for the player or developer the first time it occurs.</summary> + /// <param name="monitor">The monitor through which to log the message.</param> + /// <param name="hash">The hash of logged messages.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public static void LogOnce(this IMonitor monitor, HashSet<string> hash, string message, LogLevel level = LogLevel.Trace) + { + if (!hash.Contains(message)) + { + monitor.Log(message, level); + hash.Add(message); + } + } + + /**** + ** Exceptions + ****/ + /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> + /// <param name="exception">The error to summarise.</param> + public static string GetLogSummary(this Exception exception) + { + switch (exception) + { + case TypeLoadException ex: + return $"Failed loading type '{ex.TypeName}': {exception}"; + + case ReflectionTypeLoadException ex: + string summary = exception.ToString(); + foreach (Exception childEx in ex.LoaderExceptions) + summary += $"\n\n{childEx.GetLogSummary()}"; + return summary; + + default: + return exception.ToString(); + } + } + + /**** + ** Sprite batch + ****/ + /// <summary>Get whether the sprite batch is between a begin and end pair.</summary> + /// <param name="spriteBatch">The sprite batch to check.</param> + /// <param name="reflection">The reflection helper with which to access private fields.</param> + public static bool IsOpen(this SpriteBatch spriteBatch, Reflector reflection) + { + // get field name + const string fieldName = +#if SMAPI_FOR_WINDOWS + "inBeginEndPair"; +#else + "_beginCalled"; +#endif + + // get result + return reflection.GetPrivateField<bool>(Game1.spriteBatch, fieldName).GetValue(); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs b/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs new file mode 100644 index 00000000..b8f2c34e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/ConsoleInterceptionManager.cs @@ -0,0 +1,86 @@ +using System; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>Manages console output interception.</summary> + internal class ConsoleInterceptionManager : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The intercepting console writer.</summary> + private readonly InterceptingTextWriter Output; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the current console supports color formatting.</summary> + public bool SupportsColor { get; } + + /// <summary>The event raised when a message is written to the console directly.</summary> + public event Action<string> OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ConsoleInterceptionManager() + { + // redirect output through interceptor + this.Output = new InterceptingTextWriter(Console.Out); + this.Output.OnMessageIntercepted += line => this.OnMessageIntercepted?.Invoke(line); + Console.SetOut(this.Output); + + // test color support + this.SupportsColor = this.TestColorSupport(); + } + + /// <summary>Get an exclusive lock and write to the console output without interception.</summary> + /// <param name="action">The action to perform within the exclusive write block.</param> + public void ExclusiveWriteWithoutInterception(Action action) + { + lock (Console.Out) + { + try + { + this.Output.ShouldIntercept = false; + action(); + } + finally + { + this.Output.ShouldIntercept = true; + } + } + } + + /// <summary>Release all resources.</summary> + public void Dispose() + { + Console.SetOut(this.Output.Out); + this.Output.Dispose(); + } + + + /********* + ** private methods + *********/ + /// <summary>Test whether the current console supports color formatting.</summary> + private bool TestColorSupport() + { + try + { + this.ExclusiveWriteWithoutInterception(() => + { + Console.ForegroundColor = Console.ForegroundColor; + }); + return true; + } + catch (Exception) + { + return false; // Mono bug + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs b/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs new file mode 100644 index 00000000..9ca61b59 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/InterceptingTextWriter.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using System.Text; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>A text writer which allows intercepting output.</summary> + internal class InterceptingTextWriter : TextWriter + { + /********* + ** Accessors + *********/ + /// <summary>The underlying console output.</summary> + public TextWriter Out { get; } + + /// <summary>The character encoding in which the output is written.</summary> + public override Encoding Encoding => this.Out.Encoding; + + /// <summary>Whether to intercept console output.</summary> + public bool ShouldIntercept { get; set; } + + /// <summary>The event raised when a message is written to the console directly.</summary> + public event Action<string> OnMessageIntercepted; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="output">The underlying output writer.</param> + public InterceptingTextWriter(TextWriter output) + { + this.Out = output; + } + + /// <summary>Writes a subarray of characters to the text string or stream.</summary> + /// <param name="buffer">The character array to write data from.</param> + /// <param name="index">The character position in the buffer at which to start retrieving data.</param> + /// <param name="count">The number of characters to write.</param> + public override void Write(char[] buffer, int index, int count) + { + if (this.ShouldIntercept) + this.OnMessageIntercepted?.Invoke(new string(buffer, index, count).TrimEnd('\r', '\n')); + else + this.Out.Write(buffer, index, count); + } + + /// <summary>Writes a character to the text string or stream.</summary> + /// <param name="ch">The character to write to the text stream.</param> + /// <remarks>Console log messages from the game should be caught by <see cref="Write(char[],int,int)"/>. This method passes through anything that bypasses that method for some reason, since it's better to show it to users than hide it from everyone.</remarks> + public override void Write(char ch) + { + this.Out.Write(ch); + } + + /// <summary>Releases the unmanaged resources used by the <see cref="T:System.IO.TextWriter" /> and optionally releases the managed resources.</summary> + /// <param name="disposing">true to release both managed and unmanaged resources; false to release only unmanaged resources.</param> + protected override void Dispose(bool disposing) + { + this.OnMessageIntercepted = null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs new file mode 100644 index 00000000..8cfe0527 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Logging/LogFileManager.cs @@ -0,0 +1,57 @@ +using System; +using System.IO; + +namespace StardewModdingAPI.Framework.Logging +{ + /// <summary>Manages reading and writing to log file.</summary> + internal class LogFileManager : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying stream writer.</summary> + private readonly StreamWriter Stream; + + + /********* + ** 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 = System.IO.Path.GetDirectoryName(path); + if (logDir == null) + throw new ArgumentException($"The log path '{path}' is not valid."); + Directory.CreateDirectory(logDir); + + // open log file stream + this.Stream = new StreamWriter(path, append: false) { AutoFlush = true }; + } + + /// <summary>Write a message to the log.</summary> + /// <param name="message">The message to log.</param> + public void WriteLine(string message) + { + // always use Windows-style line endings for convenience + // (Linux/Mac editors are fine with them, Windows editors often require them) + this.Stream.Write(message + "\r\n"); + } + + /// <summary>Release all resources.</summary> + public void Dispose() + { + this.Stream.Dispose(); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs new file mode 100644 index 00000000..16032da1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/BaseHelper.cs @@ -0,0 +1,23 @@ +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>The common base class for mod helpers.</summary> + internal abstract class BaseHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod for which the helper was created.</summary> + public string ModID { get; } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + protected BaseHelper(string modID) + { + this.ModID = modID; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs new file mode 100644 index 00000000..bdedb07c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/CommandHelper.cs @@ -0,0 +1,54 @@ +using System; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides an API for managing console commands.</summary> + internal class CommandHelper : BaseHelper, ICommandHelper + { + /********* + ** Accessors + *********/ + /// <summary>The friendly mod name for this instance.</summary> + private readonly string ModName; + + /// <summary>Manages console commands.</summary> + private readonly CommandManager CommandManager; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="commandManager">Manages console commands.</param> + public CommandHelper(string modID, string modName, CommandManager commandManager) + : base(modID) + { + this.ModName = modName; + this.CommandManager = commandManager; + } + + /// <summary>Add a console command.</summary> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + public ICommandHelper Add(string name, string documentation, Action<string, string[]> callback) + { + this.CommandManager.Add(this.ModName, name, documentation, callback); + return this; + } + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + public bool Trigger(string name, string[] arguments) + { + return this.CommandManager.Trigger(name, arguments); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs new file mode 100644 index 00000000..4440ae40 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ContentHelper.cs @@ -0,0 +1,476 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +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.ModHelpers +{ + /// <summary>Provides an API for loading content assets.</summary> + internal class ContentHelper : BaseHelper, IContentHelper + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's underlying content manager.</summary> + private readonly SContentManager ContentManager; + + /// <summary>The absolute path to the mod folder.</summary> + private readonly string ModFolderPath; + + /// <summary>The path to the mod's folder, relative to the game's content folder (e.g. "../Mods/ModName").</summary> + private readonly string ModFolderPathFromContent; + + /// <summary>The friendly mod name for use in errors.</summary> + private readonly string ModName; + + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Accessors + *********/ + /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + public string CurrentLocale => this.ContentManager.GetLocale(); + + /// <summary>The game's current locale as an enum value.</summary> + public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.ContentManager.GetCurrentLanguage(); + + /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary> + internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>(); + + /// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary> + internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>(); + + /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders; + + /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentManager">SMAPI's underlying content manager.</param> + /// <param name="modFolderPath">The absolute path to the mod folder.</param> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The friendly mod name for use in errors.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public ContentHelper(SContentManager contentManager, string modFolderPath, string modID, string modName, IMonitor monitor) + : base(modID) + { + this.ContentManager = contentManager; + this.ModFolderPath = modFolderPath; + this.ModName = modName; + this.ModFolderPathFromContent = this.GetRelativePath(contentManager.FullRootDirectory, modFolderPath); + this.Monitor = monitor; + } + + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <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 + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.Load<T>(key); + + case ContentSource.ModFolder: + // get file + FileInfo file = this.GetModFile(key); + if (!file.Exists) + 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": + { + 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 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)) + { + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + this.ContentManager.Inject(assetPath, texture); + return (T)(object)texture; + } + + default: + throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); + } + + default: + throw GetContentError($"unknown content source '{source}'."); + } + } + catch (Exception ex) when (!(ex is SContentLoadException)) + { + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); + } + } + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + public string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder) + { + switch (source) + { + case ContentSource.GameContent: + return this.ContentManager.NormaliseAssetName(key); + + case ContentSource.ModFolder: + FileInfo file = this.GetModFile(key); + return this.ContentManager.NormaliseAssetName(this.GetModAssetPath(key, file.FullName)); + + default: + throw new NotSupportedException($"Unknown content source '{source}'."); + } + } + + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="key">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + public bool InvalidateCache(string key) + { + this.Monitor.Log($"Requested cache invalidation for '{key}'.", LogLevel.Trace); + string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); + return this.ContentManager.InvalidateCache((otherKey, type) => otherKey.Equals(actualKey, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> + /// <typeparam name="T">The asset type to remove from the cache.</typeparam> + /// <returns>Returns whether any assets were invalidated.</returns> + public bool InvalidateCache<T>() + { + this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace); + return this.ContentManager.InvalidateCache((key, type) => typeof(T).IsAssignableFrom(type)); + } + + /********* + ** 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> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, we try + /// for a seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixLocalMapTilesheets(Map map, string mapKey) + { + // check map info + if (!map.TileSheets.Any()) + return; + mapKey = this.ContentManager.NormaliseAssetName(mapKey); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(mapKey) ?? ""; // folder path containing the map, relative to the mod folder + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.TryLoadTilesheetImageSource(relativeMapFolder, seasonalImageSource) + ?? this.TryLoadTilesheetImageSource(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Load a tilesheet image source if the file exists.</summary> + /// <param name="relativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the loaded asset key (if it was loaded successfully).</returns> + /// <remarks>See remarks on <see cref="FixLocalMapTilesheets"/>.</remarks> + private string TryLoadTilesheetImageSource(string relativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(relativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + { + try + { + this.Load<Texture2D>(localKey); + } + catch (Exception ex) + { + throw new ContentLoadException($"The local '{imageSource}' tilesheet couldn't be loaded.", ex); + } + + return this.GetActualAssetKey(localKey); + } + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, $@"Maps\{imageSource}" }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, imageSource.Length - 4) + : candidateKey; + + try + { + this.Load<Texture2D>(contentKey, ContentSource.GameContent); + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress a asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFile(contentKey).Exists) + throw; + } + } + } + + // not found + return null; + } + + /// <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> + [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "Parameter is only used for assertion checks by design.")] + private void AssertValidAssetKeyFormat(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("The asset key or local path is empty."); + if (key.Intersect(Path.GetInvalidPathChars()).Any()) + throw new ArgumentException("The asset key or local path contains invalid characters."); + } + + /// <summary>Get a file from the mod folder.</summary> + /// <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); + + // try with default extension + if (!file.Exists && file.Extension.ToLower() != ".xnb") + { + FileInfo result = new FileInfo(path + ".xnb"); + if (result.Exists) + file = result; + } + + return file; + } + + /// <summary>Get a file from the game's content folder.</summary> + /// <param name="key">The asset key.</param> + private FileInfo GetContentFolderFile(string key) + { + // get file path + string path = Path.Combine(this.ContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path); + } + + /// <summary>Get the asset path which loads a mod folder through a content manager.</summary> + /// <param name="localPath">The file path relative to the mod's folder.</param> + /// <param name="absolutePath">The absolute file path.</param> + private string GetModAssetPath(string localPath, string absolutePath) + { +#if SMAPI_FOR_WINDOWS + // XNA doesn't allow absolute asset paths, so get a path relative to the content folder + return Path.Combine(this.ModFolderPathFromContent, localPath); +#else + // MonoGame is weird about relative paths on Mac, but allows absolute paths + return absolutePath; +#endif + } + + /// <summary>Get a directory path relative to a given root.</summary> + /// <param name="rootPath">The root path from which the path should be relative.</param> + /// <param name="targetPath">The target file path.</param> + private string GetRelativePath(string rootPath, string targetPath) + { + // convert to URIs + Uri from = new Uri(rootPath + "/"); + Uri to = new Uri(targetPath + "/"); + if (from.Scheme != to.Scheme) + throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{rootPath}'."); + + // get relative path + return Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString()) + .Replace(Path.DirectorySeparatorChar == '/' ? '\\' : '/', Path.DirectorySeparatorChar); // use correct separator for platform + } + + /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game. This is only possible if the game isn't currently drawing.</summary> + /// <param name="texture">The texture to premultiply.</param> + /// <returns>Returns a premultiplied texture.</returns> + /// <remarks>Based on <a href="https://gist.github.com/Layoric/6255384">code by Layoric</a>.</remarks> + private Texture2D PremultiplyTransparency(Texture2D texture) + { + // validate + if (Context.IsInDrawLoop) + throw new NotSupportedException("Can't load a PNG file while the game is drawing to the screen. Make sure you load content outside the draw loop."); + + // process texture + SpriteBatch spriteBatch = Game1.spriteBatch; + GraphicsDevice gpu = Game1.graphics.GraphicsDevice; + using (RenderTarget2D renderTarget = new RenderTarget2D(Game1.graphics.GraphicsDevice, texture.Width, texture.Height)) + { + // create blank render target to premultiply + gpu.SetRenderTarget(renderTarget); + gpu.Clear(Color.Black); + + // multiply each color by the source alpha, and write just the color values into the final texture + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorDestinationBlend = Blend.Zero, + ColorWriteChannels = ColorWriteChannels.Red | ColorWriteChannels.Green | ColorWriteChannels.Blue, + AlphaDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.SourceAlpha, + ColorSourceBlend = Blend.SourceAlpha + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // copy the alpha values from the source texture into the final one without multiplying them + spriteBatch.Begin(SpriteSortMode.Immediate, new BlendState + { + ColorWriteChannels = ColorWriteChannels.Alpha, + AlphaDestinationBlend = Blend.Zero, + ColorDestinationBlend = Blend.Zero, + AlphaSourceBlend = Blend.One, + ColorSourceBlend = Blend.One + }); + spriteBatch.Draw(texture, texture.Bounds, Color.White); + spriteBatch.End(); + + // release GPU + gpu.SetRenderTarget(null); + + // extract premultiplied data + Color[] data = new Color[texture.Width * texture.Height]; + renderTarget.GetData(data); + + // unset texture from GPU to regain control + gpu.Textures[0] = null; + + // update texture with premultiplied data + texture.SetData(data); + } + + return texture; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs new file mode 100644 index 00000000..665b9cf4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModHelper.cs @@ -0,0 +1,129 @@ +using System; +using System.IO; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides simplified APIs for writing mods.</summary> + internal class ModHelper : BaseHelper, IModHelper, IDisposable + { + /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* + ** Accessors + *********/ + /// <summary>The full path to the mod's folder.</summary> + public string DirectoryPath { get; } + + /// <summary>An API for loading content assets.</summary> + public IContentHelper Content { get; } + + /// <summary>An API for accessing private game code.</summary> + public IReflectionHelper Reflection { get; } + + /// <summary>an API for fetching metadata about loaded mods.</summary> + public IModRegistry ModRegistry { get; } + + /// <summary>An API for managing console commands.</summary> + public ICommandHelper ConsoleCommands { get; } + + /// <summary>An API for reading translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public ITranslationHelper Translation { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The mod's unique ID.</param> + /// <param name="modDirectory">The full path to the mod's folder.</param> + /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> + /// <param name="contentHelper">An API for loading content assets.</param> + /// <param name="commandHelper">An API for managing console commands.</param> + /// <param name="modRegistry">an API for fetching metadata about loaded mods.</param> + /// <param name="reflectionHelper">An API for accessing private game code.</param> + /// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</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 modID, string modDirectory, JsonHelper jsonHelper, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, ITranslationHelper translationHelper) + : base(modID) + { + // validate directory + if (string.IsNullOrWhiteSpace(modDirectory)) + throw new ArgumentNullException(nameof(modDirectory)); + if (!Directory.Exists(modDirectory)) + throw new InvalidOperationException("The specified mod directory does not exist."); + + // initialise + this.DirectoryPath = modDirectory; + this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); + this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); + this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry)); + this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); + this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); + this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); + } + + /**** + ** Mod config file + ****/ + /// <summary>Read the mod's configuration file (and create it if needed).</summary> + /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + public TConfig ReadConfig<TConfig>() + where TConfig : class, new() + { + TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + this.WriteConfig(config); // create file or fill in missing fields + return config; + } + + /// <summary>Save to the mod's configuration file.</summary> + /// <typeparam name="TConfig">The config class type.</typeparam> + /// <param name="config">The config settings to save.</param> + public void WriteConfig<TConfig>(TConfig config) + where TConfig : class, new() + { + this.WriteJsonFile("config.json", config); + } + + /**** + ** Generic JSON files + ****/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + public TModel ReadJsonFile<TModel>(string path) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + return this.JsonHelper.ReadJsonFile<TModel>(path); + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <param name="model">The model to save.</param> + public void WriteJsonFile<TModel>(string path, TModel model) + where TModel : class + { + path = Path.Combine(this.DirectoryPath, path); + this.JsonHelper.WriteJsonFile(path, model); + } + + + /**** + ** Disposal + ****/ + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + // nothing to dispose yet + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs new file mode 100644 index 00000000..9e824694 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides metadata about installed mods.</summary> + internal class ModRegistryHelper : BaseHelper, IModRegistry + { + /********* + ** Properties + *********/ + /// <summary>The underlying mod registry.</summary> + private readonly ModRegistry Registry; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="registry">The underlying mod registry.</param> + public ModRegistryHelper(string modID, ModRegistry registry) + : base(modID) + { + this.Registry = registry; + } + + /// <summary>Get metadata for all loaded mods.</summary> + public IEnumerable<IManifest> GetAll() + { + return this.Registry.GetAll(); + } + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + public IManifest Get(string uniqueID) + { + return this.Registry.Get(uniqueID); + } + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + public bool IsLoaded(string uniqueID) + { + return this.Registry.IsLoaded(uniqueID); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs new file mode 100644 index 00000000..14a339da --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -0,0 +1,202 @@ +using System; +using StardewModdingAPI.Framework.Reflection; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides helper methods for accessing private game code.</summary> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + internal class ReflectionHelper : BaseHelper, IReflectionHelper + { + /********* + ** Properties + *********/ + /// <summary>The underlying reflection helper.</summary> + private readonly Reflector Reflector; + + /// <summary>The mod name for error messages.</summary> + private readonly string ModName; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modName">The mod name for error messages.</param> + /// <param name="reflector">The underlying reflection helper.</param> + public ReflectionHelper(string modID, string modName, Reflector reflector) + : base(modID) + { + this.ModName = modName; + this.Reflector = reflector; + } + + /**** + ** Fields + ****/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns> + public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateField<TValue>(obj, name, required); + } + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateField<TValue>(type, name, required); + } + + /**** + ** Properties + ****/ + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateProperty<TValue>(obj, name, required); + } + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateProperty<TValue>(type, name, required); + } + + /**** + ** Field values + ** (shorthand since this is the most common case) + ****/ + /// <summary>Get the value of a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns> + /// <remarks> + /// This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>. + /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(object,string,bool)" /> instead. + /// </remarks> + public TValue GetPrivateValue<TValue>(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + IPrivateField<TValue> field = this.GetPrivateField<TValue>(obj, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /// <summary>Get the value of a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field value, or the default value for <typeparamref name="TValue"/> if the field wasn't found and <paramref name="required"/> is false.</returns> + /// <remarks> + /// This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>. + /// When <paramref name="required" /> is false, this will return the default value if reflection fails. If you need to check whether the field exists, use <see cref="GetPrivateField{TValue}(Type,string,bool)" /> instead. + /// </remarks> + public TValue GetPrivateValue<TValue>(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + IPrivateField<TValue> field = this.GetPrivateField<TValue>(type, name, required); + return field != null + ? field.GetValue() + : default(TValue); + } + + /**** + ** Methods + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateMethod(obj, name, required); + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateMethod(type, name, required); + } + + /**** + ** Methods by signature + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + { + this.AssertAccessAllowed(obj); + return this.Reflector.GetPrivateMethod(obj, name, argumentTypes, required); + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + { + this.AssertAccessAllowed(type); + return this.Reflector.GetPrivateMethod(type, name, argumentTypes, required); + } + + + /********* + ** Private methods + *********/ + /// <summary>Assert that mods can use the reflection helper to access the given type.</summary> + /// <param name="type">The type being accessed.</param> + private void AssertAccessAllowed(Type type) + { +#if !SMAPI_1_x + // validate type namespace + if (type.Namespace != null) + { + string rootSmapiNamespace = typeof(Program).Namespace; + if (type.Namespace == rootSmapiNamespace || type.Namespace.StartsWith(rootSmapiNamespace + ".")) + throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning."); + } +#endif + } + + /// <summary>Assert that mods can use the reflection helper to access the given type.</summary> + /// <param name="obj">The object being accessed.</param> + private void AssertAccessAllowed(object obj) + { + if (obj != null) + this.AssertAccessAllowed(obj.GetType()); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs new file mode 100644 index 00000000..bbe3a81a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModHelpers/TranslationHelper.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class TranslationHelper : BaseHelper, 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, Translation> 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="modID">The unique ID of the relevant mod.</param> + /// <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 modID, string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + : base(modID) + { + // save data + this.ModName = modName; + + // set locale + this.SetLocale(locale, languageCode); + } + + /// <summary>Get all translations for the current locale.</summary> + public IEnumerable<Translation> GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// <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 Translation translation); + return translation ?? new Translation(this.ModName, this.Locale, key, null); + } + + /// <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, Translation>(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.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); + } + } + } + + + /********* + ** 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/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs new file mode 100644 index 00000000..4378798c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>A minimal assembly definition resolver which resolves references to known assemblies.</summary> + internal class AssemblyDefinitionResolver : DefaultAssemblyResolver + { + /********* + ** Properties + *********/ + /// <summary>The known assemblies.</summary> + private readonly IDictionary<string, AssemblyDefinition> Loaded = new Dictionary<string, AssemblyDefinition>(); + + + /********* + ** Public methods + *********/ + /// <summary>Add known assemblies to the resolver.</summary> + /// <param name="assemblies">The known assemblies.</param> + public void Add(params AssemblyDefinition[] assemblies) + { + foreach (AssemblyDefinition assembly in assemblies) + { + this.Loaded[assembly.Name.Name] = assembly; + this.Loaded[assembly.Name.FullName] = assembly; + } + } + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="name">The assembly name.</param> + public override AssemblyDefinition Resolve(AssemblyNameReference name) => this.ResolveName(name.Name) ?? base.Resolve(name); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="name">The assembly name.</param> + /// <param name="parameters">The assembly reader parameters.</param> + public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) => this.ResolveName(name.Name) ?? base.Resolve(name, parameters); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="fullName">The assembly full name (including version, etc).</param> + public override AssemblyDefinition Resolve(string fullName) => this.ResolveName(fullName) ?? base.Resolve(fullName); + + /// <summary>Resolve an assembly reference.</summary> + /// <param name="fullName">The assembly full name (including version, etc).</param> + /// <param name="parameters">The assembly reader parameters.</param> + public override AssemblyDefinition Resolve(string fullName, ReaderParameters parameters) => this.ResolveName(fullName) ?? base.Resolve(fullName, parameters); + + + /********* + ** Private methods + *********/ + /// <summary>Resolve a known assembly definition based on its short or full name.</summary> + /// <param name="name">The assembly's short or full name.</param> + private AssemblyDefinition ResolveName(string name) + { + return this.Loaded.ContainsKey(name) + ? this.Loaded[name] + : null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs new file mode 100644 index 00000000..11be19fc --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoadStatus.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates the result of an assembly load.</summary> + internal enum AssemblyLoadStatus + { + /// <summary>The assembly was loaded successfully.</summary> + Okay = 1, + + /// <summary>The assembly could not be loaded.</summary> + Failed = 2, + + /// <summary>The assembly is already loaded.</summary> + AlreadyLoaded = 3 + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs new file mode 100644 index 00000000..32988f97 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyLoader.cs @@ -0,0 +1,317 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Metadata; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Preprocesses and loads mod assemblies.</summary> + internal class AssemblyLoader + { + /********* + ** Properties + *********/ + /// <summary>Metadata for mapping assemblies to the current platform.</summary> + private readonly PlatformAssemblyMap AssemblyMap; + + /// <summary>A type => assembly lookup for types which should be rewritten.</summary> + private readonly IDictionary<string, Assembly> TypeAssemblies; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="targetPlatform">The current game platform.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public AssemblyLoader(Platform targetPlatform, IMonitor monitor) + { + this.Monitor = monitor; + this.AssemblyMap = Constants.GetAssemblyMap(targetPlatform); + + // generate type => assembly lookup for types which should be rewritten + this.TypeAssemblies = new Dictionary<string, Assembly>(); + foreach (Assembly assembly in this.AssemblyMap.Targets) + { + ModuleDefinition module = this.AssemblyMap.TargetModules[assembly]; + foreach (TypeDefinition type in module.GetTypes()) + { + if (!type.IsPublic) + continue; // no need to rewrite + if (type.Namespace.Contains("<")) + continue; // ignore assembly metadata + this.TypeAssemblies[type.FullName] = assembly; + } + } + } + + /// <summary>Preprocess and load an assembly.</summary> + /// <param name="mod">The mod for which the assembly is being loaded.</param> + /// <param name="assemblyPath">The assembly file path.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + public Assembly Load(IModMetadata mod, string assemblyPath, bool assumeCompatible) + { + // get referenced local assemblies + AssemblyParseResult[] assemblies; + { + AssemblyDefinitionResolver resolver = new AssemblyDefinitionResolver(); + HashSet<string> visitedAssemblyNames = new HashSet<string>(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded + assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, resolver).ToArray(); + } + + // validate load + if (!assemblies.Any() || assemblies[0].Status == AssemblyLoadStatus.Failed) + { + throw new SAssemblyLoadFailedException(!File.Exists(assemblyPath) + ? $"Could not load '{assemblyPath}' because it doesn't exist." + : $"Could not load '{assemblyPath}'." + ); + } + if (assemblies.Last().Status == AssemblyLoadStatus.AlreadyLoaded) // mod assembly is last in dependency order + throw new SAssemblyLoadFailedException($"Could not load '{assemblyPath}' because it was already loaded. Do you have two copies of this mod?"); + + // rewrite & load assemblies in leaf-to-root order + bool oneAssembly = assemblies.Length == 1; + Assembly lastAssembly = null; + HashSet<string> loggedMessages = new HashSet<string>(); + foreach (AssemblyParseResult assembly in assemblies) + { + if (assembly.Status == AssemblyLoadStatus.AlreadyLoaded) + continue; + + bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " "); + if (changed) + { + if (!oneAssembly) + this.Monitor.Log($" Loading {assembly.File.Name} (rewritten in memory)...", LogLevel.Trace); + using (MemoryStream outStream = new MemoryStream()) + { + assembly.Definition.Write(outStream); + byte[] bytes = outStream.ToArray(); + lastAssembly = Assembly.Load(bytes); + } + } + else + { + if (!oneAssembly) + this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace); + lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); + } + } + + // last assembly loaded is the root + return lastAssembly; + } + + /// <summary>Resolve an assembly by its name.</summary> + /// <param name="name">The assembly name.</param> + /// <remarks> + /// This implementation returns the first loaded assembly which matches the short form of + /// the assembly name, to resolve assembly resolution issues when rewriting + /// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>, + /// the implicit assumption is that loading the exact assembly failed. + /// </remarks> + public Assembly ResolveAssembly(string name) + { + string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) + return AppDomain.CurrentDomain + .GetAssemblies() + .FirstOrDefault(p => p.GetName().Name == shortName); + } + + + /********* + ** Private methods + *********/ + /**** + ** Assembly parsing + ****/ + /// <summary>Get a list of referenced local assemblies starting from the mod assembly, ordered from leaf to root.</summary> + /// <param name="file">The assembly file to load.</param> + /// <param name="visitedAssemblyNames">The assembly names that should be skipped.</param> + /// <param name="assemblyResolver">A resolver which resolves references to known assemblies.</param> + /// <returns>Returns the rewrite metadata for the preprocessed assembly.</returns> + private IEnumerable<AssemblyParseResult> GetReferencedLocalAssemblies(FileInfo file, HashSet<string> visitedAssemblyNames, IAssemblyResolver assemblyResolver) + { + // validate + if (file.Directory == null) + throw new InvalidOperationException($"Could not get directory from file path '{file.FullName}'."); + if (!file.Exists) + yield break; // not a local assembly + + // read assembly + byte[] assemblyBytes = File.ReadAllBytes(file.FullName); + AssemblyDefinition assembly; + using (Stream readStream = new MemoryStream(assemblyBytes)) + assembly = AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Deferred) { AssemblyResolver = assemblyResolver }); + + // skip if already visited + if (visitedAssemblyNames.Contains(assembly.Name.Name)) + yield return new AssemblyParseResult(file, null, AssemblyLoadStatus.AlreadyLoaded); + visitedAssemblyNames.Add(assembly.Name.Name); + + // yield referenced assemblies + foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) + { + FileInfo dependencyFile = new FileInfo(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); + foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) + yield return result; + } + + // yield assembly + yield return new AssemblyParseResult(file, assembly, AssemblyLoadStatus.Okay); + } + + /**** + ** Assembly rewriting + ****/ + /// <summary>Rewrite the types referenced by an assembly.</summary> + /// <param name="mod">The mod for which the assembly is being loaded.</param> + /// <param name="assembly">The assembly to rewrite.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> + /// <param name="logPrefix">A string to prefix to log messages.</param> + /// <returns>Returns whether the assembly was modified.</returns> + /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, bool assumeCompatible, HashSet<string> loggedMessages, string logPrefix) + { + ModuleDefinition module = assembly.MainModule; + string filename = $"{assembly.Name.Name}.dll"; + + // swap assembly references if needed (e.g. XNA => MonoGame) + bool platformChanged = false; + for (int i = 0; i < module.AssemblyReferences.Count; i++) + { + // remove old assembly reference + if (this.AssemblyMap.RemoveNames.Any(name => module.AssemblyReferences[i].Name == name)) + { + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewriting {filename} for OS..."); + platformChanged = true; + module.AssemblyReferences.RemoveAt(i); + i--; + } + } + if (platformChanged) + { + // add target assembly references + foreach (AssemblyNameReference target in this.AssemblyMap.TargetReferences.Values) + module.AssemblyReferences.Add(target); + + // rewrite type scopes to use target assemblies + IEnumerable<TypeReference> typeReferences = module.GetTypeReferences().OrderBy(p => p.FullName); + foreach (TypeReference type in typeReferences) + this.ChangeTypeScope(type); + } + + // find (and optionally rewrite) incompatible instructions + bool anyRewritten = false; + IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers().ToArray(); + foreach (MethodDefinition method in this.GetMethods(module)) + { + // check method definition + foreach (IInstructionHandler handler in handlers) + { + InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + if (result == InstructionHandleResult.Rewritten) + anyRewritten = true; + } + + // check CIL instructions + ILProcessor cil = method.Body.GetILProcessor(); + foreach (Instruction instruction in cil.Body.Instructions.ToArray()) + { + foreach (IInstructionHandler handler in handlers) + { + InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + if (result == InstructionHandleResult.Rewritten) + anyRewritten = true; + } + } + } + + return platformChanged || anyRewritten; + } + + /// <summary>Process the result from an instruction handler.</summary> + /// <param name="mod">The mod being analysed.</param> + /// <param name="handler">The instruction handler.</param> + /// <param name="result">The result returned by the handler.</param> + /// <param name="loggedMessages">The messages already logged for the current mod.</param> + /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> + /// <param name="logPrefix">A string to prefix to log messages.</param> + /// <param name="filename">The assembly filename for log messages.</param> + private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, bool assumeCompatible, string filename) + { + switch (result) + { + case InstructionHandleResult.Rewritten: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Rewrote {filename} to fix {handler.NounPhrase}..."); + break; + + case InstructionHandleResult.NotCompatible: + if (!assumeCompatible) + throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly.", LogLevel.Warn); + break; + + case InstructionHandleResult.DetectedGamePatch: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected game patcher ({handler.NounPhrase}) in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} patches the game, which may impact game stability. If you encounter problems, try removing this mod first.", LogLevel.Warn); + break; + + case InstructionHandleResult.DetectedSaveSerialiser: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}."); + this.Monitor.LogOnce(loggedMessages, $"{mod.DisplayName} seems to change the save serialiser. It may change your saves in such a way that they won't work without this mod in the future.", LogLevel.Warn); + break; + + case InstructionHandleResult.None: + break; + + default: + throw new NotSupportedException($"Unrecognised instruction handler result '{result}'."); + } + } + + /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> + /// <param name="type">The type reference to rewrite.</param> + private void ChangeTypeScope(TypeReference type) + { + // check skip conditions + if (type == null || type.FullName.StartsWith("System.")) + return; + + // get assembly + if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly assembly)) + return; + + // replace scope + AssemblyNameReference assemblyRef = this.AssemblyMap.TargetReferences[assembly]; + type.Scope = assemblyRef; + } + + /// <summary>Get all methods in a module.</summary> + /// <param name="module">The module to search.</param> + private IEnumerable<MethodDefinition> GetMethods(ModuleDefinition module) + { + return ( + from type in module.GetTypes() + where type.HasMethods + from method in type.Methods + where method.HasBody + select method + ); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs new file mode 100644 index 00000000..b56a776c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/AssemblyParseResult.cs @@ -0,0 +1,36 @@ +using System.IO; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata about a parsed assembly definition.</summary> + internal class AssemblyParseResult + { + /********* + ** Accessors + *********/ + /// <summary>The original assembly file.</summary> + public readonly FileInfo File; + + /// <summary>The assembly definition.</summary> + public readonly AssemblyDefinition Definition; + + /// <summary>The result of the assembly load.</summary> + public AssemblyLoadStatus Status; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="file">The original assembly file.</param> + /// <param name="assembly">The assembly definition.</param> + /// <param name="status">The result of the assembly load.</param> + public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly, AssemblyLoadStatus status) + { + this.File = file; + this.Definition = assembly; + this.Status = status; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs new file mode 100644 index 00000000..e4beb7a9 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given event.</summary> + internal class EventFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The event name for which to find references.</summary> + private readonly string EventName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="eventName">The event name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public EventFinder(string fullTypeName, string eventName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.EventName = eventName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{eventName} event"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && (methodRef.Name == "add_" + this.EventName || methodRef.Name == "remove_" + this.EventName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs new file mode 100644 index 00000000..00805815 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/FieldFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given field.</summary> + internal class FieldFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The field name for which to find references.</summary> + private readonly string FieldName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="fieldName">The field name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public FieldFinder(string fullTypeName, string fieldName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.FieldName = fieldName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{fieldName} field"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + return + fieldRef != null + && fieldRef.DeclaringType.FullName == this.FullTypeName + && fieldRef.Name == this.FieldName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs new file mode 100644 index 00000000..5358f181 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/MethodFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given method.</summary> + internal class MethodFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The method name for which to find references.</summary> + private readonly string MethodName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="methodName">The method name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public MethodFinder(string fullTypeName, string methodName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.MethodName = methodName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{methodName} method"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && methodRef.Name == this.MethodName; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs new file mode 100644 index 00000000..e54c86cf --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/PropertyFinder.cs @@ -0,0 +1,82 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given property.</summary> + internal class PropertyFinder : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The property name for which to find references.</summary> + private readonly string PropertyName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name for which to find references.</param> + /// <param name="propertyName">The property name for which to find references.</param> + /// <param name="result">The result to return for matching instructions.</param> + public PropertyFinder(string fullTypeName, string propertyName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.PropertyName = propertyName; + this.Result = result; + this.NounPhrase = $"{fullTypeName}.{propertyName} property"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && methodRef.DeclaringType.FullName == this.FullTypeName + && (methodRef.Name == "get_" + this.PropertyName || methodRef.Name == "set_" + this.PropertyName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs new file mode 100644 index 00000000..45349def --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -0,0 +1,133 @@ +using System.Linq; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Finders +{ + /// <summary>Finds incompatible CIL instructions that reference a given type.</summary> + internal class TypeFinder : IInstructionHandler + { + /********* + ** Accessors + *********/ + /// <summary>The full type name for which to find references.</summary> + private readonly string FullTypeName; + + /// <summary>The result to return for matching instructions.</summary> + private readonly InstructionHandleResult Result; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullTypeName">The full type name to match.</param> + /// <param name="result">The result to return for matching instructions.</param> + public TypeFinder(string fullTypeName, InstructionHandleResult result) + { + this.FullTypeName = fullTypeName; + this.Result = result; + this.NounPhrase = $"{fullTypeName} type"; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(method) + ? this.Result + : InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public virtual InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return this.IsMatch(instruction) + ? this.Result + : InstructionHandleResult.None; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="method">The method deifnition.</param> + protected bool IsMatch(MethodDefinition method) + { + if (this.IsMatch(method.ReturnType)) + return true; + + foreach (VariableDefinition variable in method.Body.Variables) + { + if (this.IsMatch(variable.VariableType)) + return true; + } + + return false; + } + + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + protected bool IsMatch(Instruction instruction) + { + // field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + return + this.IsMatch(fieldRef.DeclaringType) // field on target class + || this.IsMatch(fieldRef.FieldType); // field value is target class + } + + // method reference + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null) + { + return + this.IsMatch(methodRef.DeclaringType) // method on target class + || this.IsMatch(methodRef.ReturnType) // method returns target class + || methodRef.Parameters.Any(p => this.IsMatch(p.ParameterType)); // method parameters + } + + return false; + } + + /// <summary>Get whether a type reference matches the expected type.</summary> + /// <param name="type">The type to check.</param> + protected bool IsMatch(TypeReference type) + { + // root type + if (type.FullName == this.FullTypeName) + return true; + + // generic arguments + if (type is GenericInstanceType genericType) + { + if (genericType.GenericArguments.Any(this.IsMatch)) + return true; + } + + // generic parameters (e.g. constraints) + if (type.GenericParameters.Any(this.IsMatch)) + return true; + + return false; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs b/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs new file mode 100644 index 00000000..8830cc74 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/IInstructionHandler.cs @@ -0,0 +1,34 @@ +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Performs predefined logic for detected CIL instructions.</summary> + internal interface IInstructionHandler + { + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the handler matches.</summary> + string NounPhrase { get; } + + + /********* + ** Methods + *********/ + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged); + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged); + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs b/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs new file mode 100644 index 00000000..17ec24b1 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/IncompatibleInstructionException.cs @@ -0,0 +1,35 @@ +using System; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>An exception raised when an incompatible instruction is found while loading a mod assembly.</summary> + internal class IncompatibleInstructionException : Exception + { + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase which describes the incompatible instruction that was found.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> + public IncompatibleInstructionException(string nounPhrase) + : base($"Found an incompatible CIL instruction ({nounPhrase}).") + { + this.NounPhrase = nounPhrase; + } + + /// <summary>Construct an instance.</summary> + /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> + /// <param name="message">A message which describes the error.</param> + public IncompatibleInstructionException(string nounPhrase, string message) + : base(message) + { + this.NounPhrase = nounPhrase; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs new file mode 100644 index 00000000..95d57ef2 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates how an instruction was handled.</summary> + internal enum InstructionHandleResult + { + /// <summary>No special handling is needed.</summary> + None, + + /// <summary>The instruction was successfully rewritten for compatibility.</summary> + Rewritten, + + /// <summary>The instruction is not compatible and can't be rewritten for compatibility.</summary> + NotCompatible, + + /// <summary>The instruction is compatible, but patches the game in a way that may impact stability.</summary> + DetectedGamePatch, + + /// <summary>The instruction is compatible, but affects the save serializer in a way that may make saves unloadable without the mod.</summary> + DetectedSaveSerialiser + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs new file mode 100644 index 00000000..075e237a --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/InvalidModStateException.cs @@ -0,0 +1,14 @@ +using System; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>An exception which indicates that something went seriously wrong while loading mods, and SMAPI should abort outright.</summary> + internal class InvalidModStateException : Exception + { + /// <summary>Construct an instance.</summary> + /// <param name="message">The error message.</param> + /// <param name="ex">The underlying exception, if any.</param> + public InvalidModStateException(string message, Exception ex = null) + : base(message, ex) { } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs new file mode 100644 index 00000000..0774b487 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModDependencyStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>The status of a given mod in the dependency-sorting algorithm.</summary> + internal enum ModDependencyStatus + { + /// <summary>The mod hasn't been visited yet.</summary> + Queued, + + /// <summary>The mod is currently being analysed as part of a dependency chain.</summary> + Checking, + + /// <summary>The mod has already been sorted.</summary> + Sorted, + + /// <summary>The mod couldn't be sorted due to a metadata issue (e.g. missing dependencies).</summary> + Failed + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs new file mode 100644 index 00000000..ab590e10 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadata.cs @@ -0,0 +1,68 @@ +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata for a mod.</summary> + internal class ModMetadata : IModMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + public string DisplayName { get; } + + /// <summary>The mod's full directory path.</summary> + public string DirectoryPath { get; } + + /// <summary>The mod manifest.</summary> + public IManifest Manifest { get; } + + /// <summary>Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> + public ModCompatibility Compatibility { get; } + + /// <summary>The metadata resolution status.</summary> + public ModMetadataStatus Status { get; private set; } + + /// <summary>The reason the metadata is invalid, if any.</summary> + public string Error { get; private set; } + + /// <summary>The mod instance (if it was loaded).</summary> + public IMod Mod { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="displayName">The mod's display name.</param> + /// <param name="directoryPath">The mod's full directory path.</param> + /// <param name="manifest">The mod manifest.</param> + /// <param name="compatibility">Optional metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> + public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModCompatibility compatibility) + { + this.DisplayName = displayName; + this.DirectoryPath = directoryPath; + this.Manifest = manifest; + this.Compatibility = compatibility; + } + + /// <summary>Set the mod status.</summary> + /// <param name="status">The metadata resolution status.</param> + /// <param name="error">The reason the metadata is invalid, if any.</param> + /// <returns>Return the instance for chaining.</returns> + public IModMetadata SetStatus(ModMetadataStatus status, string error = null) + { + this.Status = status; + this.Error = error; + return this; + } + + /// <summary>Set the mod instance.</summary> + /// <param name="mod">The mod instance to set.</param> + public IModMetadata SetMod(IMod mod) + { + this.Mod = mod; + return this; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs new file mode 100644 index 00000000..ab65f7b4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModMetadataStatus.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Indicates the status of a mod's metadata resolution.</summary> + internal enum ModMetadataStatus + { + /// <summary>The mod has been found, but hasn't been processed yet.</summary> + Found, + + /// <summary>The mod cannot be loaded.</summary> + Failed + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs new file mode 100644 index 00000000..6b19db5c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/ModResolver.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Finds and processes mod metadata.</summary> + internal class ModResolver + { + /********* + ** Public methods + *********/ + /// <summary>Get manifest metadata for each folder in the given root path.</summary> + /// <param name="rootPath">The root path to search for mods.</param> + /// <param name="jsonHelper">The JSON helper with which to read manifests.</param> + /// <param name="compatibilityRecords">Metadata about mods that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</param> + /// <param name="disabledMods">Metadata about mods that SMAPI should consider obsolete and not load.</param> + /// <returns>Returns the manifests by relative folder.</returns> + public IEnumerable<IModMetadata> ReadManifests(string rootPath, JsonHelper jsonHelper, IEnumerable<ModCompatibility> compatibilityRecords, IEnumerable<DisabledMod> disabledMods) + { + compatibilityRecords = compatibilityRecords.ToArray(); + disabledMods = disabledMods.ToArray(); + + foreach (DirectoryInfo modDir in this.GetModFolders(rootPath)) + { + // read file + Manifest manifest = null; + string path = Path.Combine(modDir.FullName, "manifest.json"); + string error = null; + try + { + // read manifest + manifest = jsonHelper.ReadJsonFile<Manifest>(path); + + // validate + if (manifest == null) + { + error = File.Exists(path) + ? "its manifest is invalid." + : "it doesn't have a manifest."; + } + else if (string.IsNullOrWhiteSpace(manifest.EntryDll)) + error = "its manifest doesn't set an entry DLL."; + } + catch (SParseException ex) + { + error = $"parsing its manifest failed: {ex.Message}"; + } + catch (Exception ex) + { + error = $"parsing its manifest failed:\n{ex.GetLogSummary()}"; + } + + // validate metadata + ModCompatibility compatibility = null; + if (manifest != null) + { + // get unique key for lookups + string key = !string.IsNullOrWhiteSpace(manifest.UniqueID) ? manifest.UniqueID : manifest.EntryDll; + + // check if mod should be disabled + DisabledMod disabledMod = disabledMods.FirstOrDefault(mod => mod.ID.Contains(key, StringComparer.InvariantCultureIgnoreCase)); + if (disabledMod != null) + error = $"it's obsolete: {disabledMod.ReasonPhrase}"; + + // get compatibility record + compatibility = ( + from mod in compatibilityRecords + where + mod.ID.Any(p => p.Matches(key, manifest)) + && (mod.LowerVersion == null || !manifest.Version.IsOlderThan(mod.LowerVersion)) + && !manifest.Version.IsNewerThan(mod.UpperVersion) + select mod + ).FirstOrDefault(); + } + + // build metadata + string displayName = !string.IsNullOrWhiteSpace(manifest?.Name) + ? manifest.Name + : modDir.FullName.Replace(rootPath, "").Trim('/', '\\'); + ModMetadataStatus status = error == null + ? ModMetadataStatus.Found + : ModMetadataStatus.Failed; + + yield return new ModMetadata(displayName, modDir.FullName, manifest, compatibility).SetStatus(status, error); + } + } + + /// <summary>Validate manifest metadata.</summary> + /// <param name="mods">The mod manifests to validate.</param> + /// <param name="apiVersion">The current SMAPI version.</param> + public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion) + { + mods = mods.ToArray(); + + // validate each manifest + foreach (IModMetadata mod in mods) + { + // skip if already failed + if (mod.Status == ModMetadataStatus.Failed) + continue; + + // validate compatibility + { + ModCompatibility compatibility = mod.Compatibility; + if (compatibility?.Compatibility == ModCompatibilityType.AssumeBroken) + { +#if SMAPI_1_x + bool hasOfficialUrl = mod.Compatibility.UpdateUrls.Length > 0; + bool hasUnofficialUrl = mod.Compatibility.UpdateUrls.Length > 1; + + string reasonPhrase = compatibility.ReasonPhrase ?? "it's not compatible with the latest version of the game or SMAPI"; + string error = $"{reasonPhrase}. Please check for a version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion.ToString()} here:"; + if (hasOfficialUrl) + error += !hasUnofficialUrl ? $" {compatibility.UpdateUrls[0]}" : $"{Environment.NewLine}- official mod: {compatibility.UpdateUrls[0]}"; + if (hasUnofficialUrl) + error += $"{Environment.NewLine}- unofficial update: {compatibility.UpdateUrls[1]}"; +#else + string reasonPhrase = compatibility.ReasonPhrase ?? "it's no longer compatible"; + string error = $"{reasonPhrase}. Please check for a "; + if (mod.Manifest.Version.Equals(compatibility.UpperVersion) && compatibility.UpperVersionLabel == null) + error += "newer version"; + else + error += $"version newer than {compatibility.UpperVersionLabel ?? compatibility.UpperVersion.ToString()}"; + error += " at " + string.Join(" or ", compatibility.UpdateUrls); +#endif + + mod.SetStatus(ModMetadataStatus.Failed, error); + continue; + } + } + + // validate SMAPI version + if (mod.Manifest.MinimumApiVersion?.IsNewerThan(apiVersion) == true) + { + mod.SetStatus(ModMetadataStatus.Failed, $"it needs SMAPI {mod.Manifest.MinimumApiVersion} or later. Please update SMAPI to the latest version to use this mod."); + continue; + } + + // validate DLL path + string assemblyPath = Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll); + if (!File.Exists(assemblyPath)) + { + mod.SetStatus(ModMetadataStatus.Failed, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } + + // validate required fields +#if !SMAPI_1_x + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + mod.SetStatus(ModMetadataStatus.Failed, $"its manifest is missing required fields ({string.Join(", ", missingFields)})."); + } +#endif + } + + // validate IDs are unique +#if !SMAPI_1_x + { + var duplicatesByID = mods + .GroupBy(mod => mod.Manifest?.UniqueID?.Trim(), mod => mod, StringComparer.InvariantCultureIgnoreCase) + .Where(p => p.Count() > 1); + foreach (var group in duplicatesByID) + { + foreach (IModMetadata mod in group) + { + if (mod.Status == ModMetadataStatus.Failed) + continue; // don't replace metadata error + mod.SetStatus(ModMetadataStatus.Failed, $"its unique ID '{mod.Manifest.UniqueID}' is used by multiple mods ({string.Join(", ", group.Select(p => p.DisplayName))})."); + } + } + } +#endif + } + + /// <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) + { + // initialise metadata + mods = mods.ToArray(); + var sortedMods = new Stack<IModMetadata>(); + var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); + + // handle failed mods + foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed)) + { + 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(); + } + + + /********* + ** Private methods + *********/ + /// <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> + /// <param name="states">The dependency state for each mod.</param> + /// <param name="sortedMods">The list in which to save mods sorted by dependency order.</param> + /// <param name="currentChain">The current change of mod dependencies.</param> + /// <returns>Returns the mod dependency status.</returns> + private ModDependencyStatus ProcessDependencies(IModMetadata[] mods, IModMetadata mod, IDictionary<IModMetadata, ModDependencyStatus> states, Stack<IModMetadata> sortedMods, ICollection<IModMetadata> currentChain) + { + // check if already visited + switch (states[mod]) + { + // already sorted or failed + case ModDependencyStatus.Sorted: + case ModDependencyStatus.Failed: + return states[mod]; + + // dependency loop + case ModDependencyStatus.Checking: + // This should never happen. The higher-level mod checks if the dependency is + // already being checked, so it can fail without visiting a mod twice. If this + // case is hit, that logic didn't catch the dependency loop for some reason. + throw new InvalidModStateException($"A dependency loop was not caught by the calling iteration ({string.Join(" => ", currentChain.Select(p => p.DisplayName))} => {mod.DisplayName}))."); + + // not visited yet, start processing + case ModDependencyStatus.Queued: + break; + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } + + // no dependencies, mark sorted + if (mod.Manifest.Dependencies == null || !mod.Manifest.Dependencies.Any()) + { + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } + + // get dependencies + var dependencies = + ( + from entry in mod.Manifest.Dependencies + let dependencyMod = mods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, entry.UniqueID, StringComparison.InvariantCultureIgnoreCase)) + orderby entry.UniqueID + select new + { + ID = entry.UniqueID, + MinVersion = entry.MinimumVersion, + Mod = dependencyMod, + IsRequired = +#if SMAPI_1_x + true +#else + entry.IsRequired +#endif + } + ) + .ToArray(); + + // missing required dependencies, mark failed + { + string[] failedIDs = (from entry in dependencies where entry.IsRequired && entry.Mod == null select entry.ID).ToArray(); + if (failedIDs.Any()) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it requires mods which aren't installed ({string.Join(", ", failedIDs)})."); + return states[mod] = ModDependencyStatus.Failed; + } + } + + // dependency min version not met, mark failed + { + string[] failedLabels = + ( + from entry in dependencies + where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) + select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" + ) + .ToArray(); + if (failedLabels.Any()) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs newer versions of some mods: {string.Join(", ", failedLabels)}."); + return states[mod] = ModDependencyStatus.Failed; + } + } + + // process dependencies + { + states[mod] = ModDependencyStatus.Checking; + + // recursively sort dependencies + foreach (var dependency in dependencies) + { + IModMetadata requiredMod = dependency.Mod; + var subchain = new List<IModMetadata>(currentChain) { mod }; + + // ignore missing optional dependency + if (!dependency.IsRequired && requiredMod == null) + continue; + + // detect dependency loop + if (states[requiredMod] == ModDependencyStatus.Checking) + { + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"its dependencies have a circular reference: {string.Join(" => ", subchain.Select(p => p.DisplayName))} => {requiredMod.DisplayName})."); + return states[mod] = ModDependencyStatus.Failed; + } + + // recursively process each dependency + var substatus = this.ProcessDependencies(mods, requiredMod, states, sortedMods, subchain); + switch (substatus) + { + // sorted successfully + case ModDependencyStatus.Sorted: + break; + + // failed, which means this mod can't be loaded either + case ModDependencyStatus.Failed: + sortedMods.Push(mod); + mod.SetStatus(ModMetadataStatus.Failed, $"it needs the '{requiredMod.DisplayName}' mod, which couldn't be loaded."); + return states[mod] = ModDependencyStatus.Failed; + + // unexpected status + case ModDependencyStatus.Queued: + case ModDependencyStatus.Checking: + throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status."); + + // sanity check + default: + throw new InvalidModStateException($"Unknown dependency status '{states[mod]}'."); + } + } + + // all requirements sorted successfully + sortedMods.Push(mod); + return states[mod] = ModDependencyStatus.Sorted; + } + } + + /// <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> + private IEnumerable<DirectoryInfo> GetModFolders(string rootPath) + { + foreach (string modRootPath in Directory.GetDirectories(rootPath)) + { + DirectoryInfo directory = new DirectoryInfo(modRootPath); + + // if a folder only contains another folder, check the inner folder instead + while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) + directory = directory.GetDirectories().First(); + + yield return directory; + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Platform.cs b/src/StardewModdingAPI/Framework/ModLoading/Platform.cs new file mode 100644 index 00000000..45e881c4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Platform.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>The game's platform version.</summary> + internal enum Platform + { + /// <summary>The Linux/Mac version of the game.</summary> + Mono, + + /// <summary>The Windows version of the game.</summary> + Windows + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs new file mode 100644 index 00000000..463f45e8 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/PlatformAssemblyMap.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using Mono.Cecil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Metadata for mapping assemblies to the current <see cref="Platform"/>.</summary> + internal class PlatformAssemblyMap + { + /********* + ** Accessors + *********/ + /**** + ** Data + ****/ + /// <summary>The target game platform.</summary> + public readonly Platform TargetPlatform; + + /// <summary>The short assembly names to remove as assembly reference, and replace with the <see cref="Targets"/>. These should be short names (like "Stardew Valley").</summary> + public readonly string[] RemoveNames; + + /**** + ** Metadata + ****/ + /// <summary>The assemblies to target. Equivalent types should be rewritten to use these assemblies.</summary> + public readonly Assembly[] Targets; + + /// <summary>An assembly => reference cache.</summary> + public readonly IDictionary<Assembly, AssemblyNameReference> TargetReferences; + + /// <summary>An assembly => module cache.</summary> + public readonly IDictionary<Assembly, ModuleDefinition> TargetModules; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="targetPlatform">The target game platform.</param> + /// <param name="removeAssemblyNames">The assembly short names to remove (like <c>Stardew Valley</c>).</param> + /// <param name="targetAssemblies">The assemblies to target.</param> + public PlatformAssemblyMap(Platform targetPlatform, string[] removeAssemblyNames, Assembly[] targetAssemblies) + { + // save data + this.TargetPlatform = targetPlatform; + this.RemoveNames = removeAssemblyNames; + + // cache assembly metadata + this.Targets = targetAssemblies; + this.TargetReferences = this.Targets.ToDictionary(assembly => assembly, assembly => AssemblyNameReference.Parse(assembly.FullName)); + this.TargetModules = this.Targets.ToDictionary(assembly => assembly, assembly => ModuleDefinition.ReadModule(assembly.Modules.Single().FullyQualifiedName)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs b/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs new file mode 100644 index 00000000..56a60a72 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/RewriteHelper.cs @@ -0,0 +1,94 @@ +using System; +using System.Linq; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading +{ + /// <summary>Provides helper methods for field rewriters.</summary> + internal static class RewriteHelper + { + /********* + ** Public methods + *********/ + /// <summary>Get the field reference from an instruction if it matches.</summary> + /// <param name="instruction">The IL instruction.</param> + public static FieldReference AsFieldReference(Instruction instruction) + { + return instruction.OpCode == OpCodes.Ldfld || instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld + ? (FieldReference)instruction.Operand + : null; + } + + /// <summary>Get the method reference from an instruction if it matches.</summary> + /// <param name="instruction">The IL instruction.</param> + public static MethodReference AsMethodReference(Instruction instruction) + { + return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt + ? (MethodReference)instruction.Operand + : null; + } + + /// <summary>Get whether a type matches a type reference.</summary> + /// <param name="type">The defined type.</param> + /// <param name="reference">The type reference.</param> + public static bool IsSameType(Type type, TypeReference reference) + { + // same namespace & name + if (type.Namespace != reference.Namespace || type.Name != reference.Name) + return false; + + // same generic parameters + if (type.IsGenericType) + { + if (!reference.IsGenericInstance) + return false; + + Type[] defGenerics = type.GetGenericArguments(); + TypeReference[] refGenerics = ((GenericInstanceType)reference).GenericArguments.ToArray(); + if (defGenerics.Length != refGenerics.Length) + return false; + for (int i = 0; i < defGenerics.Length; i++) + { + if (!RewriteHelper.IsSameType(defGenerics[i], refGenerics[i])) + return false; + } + } + + return true; + } + + /// <summary>Get whether a method definition matches the signature expected by a method reference.</summary> + /// <param name="definition">The method definition.</param> + /// <param name="reference">The method reference.</param> + public static bool HasMatchingSignature(MethodInfo definition, MethodReference reference) + { + // same name + if (definition.Name != reference.Name) + return false; + + // same arguments + ParameterInfo[] definitionParameters = definition.GetParameters(); + ParameterDefinition[] referenceParameters = reference.Parameters.ToArray(); + if (referenceParameters.Length != definitionParameters.Length) + return false; + for (int i = 0; i < referenceParameters.Length; i++) + { + if (!RewriteHelper.IsSameType(definitionParameters[i].ParameterType, referenceParameters[i].ParameterType)) + return false; + } + return true; + } + + /// <summary>Get whether a type has a method whose signature matches the one expected by a method reference.</summary> + /// <param name="type">The type to check.</param> + /// <param name="reference">The method reference.</param> + public static bool HasMatchingSignature(Type type, MethodReference reference) + { + return type + .GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly | BindingFlags.Public) + .Any(method => RewriteHelper.HasMatchingSignature(method, reference)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs new file mode 100644 index 00000000..63358b39 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -0,0 +1,50 @@ +using System; +using System.Reflection; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites references to one field with another.</summary> + internal class FieldReplaceRewriter : FieldFinder + { + /********* + ** Properties + *********/ + /// <summary>The new field to reference.</summary> + private readonly FieldInfo ToField; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The type whose field to which references should be rewritten.</param> + /// <param name="fromFieldName">The field name to rewrite.</param> + /// <param name="toFieldName">The new field name to reference.</param> + public FieldReplaceRewriter(Type type, string fromFieldName, string toFieldName) + : base(type.FullName, fromFieldName, InstructionHandleResult.None) + { + this.ToField = type.GetField(toFieldName); + if (this.ToField == null) + throw new InvalidOperationException($"The {type.FullName} class doesn't have a {toFieldName} field."); + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction)) + return InstructionHandleResult.None; + + FieldReference newRef = module.Import(this.ToField); + cil.Replace(instruction, cil.Create(instruction.OpCode, newRef)); + return InstructionHandleResult.Rewritten; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs new file mode 100644 index 00000000..a20b8bee --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/FieldToPropertyRewriter.cs @@ -0,0 +1,51 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites field references into property references.</summary> + internal class FieldToPropertyRewriter : FieldFinder + { + /********* + ** Properties + *********/ + /// <summary>The type whose field to which references should be rewritten.</summary> + private readonly Type Type; + + /// <summary>The field name to rewrite.</summary> + private readonly string FieldName; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The type whose field to which references should be rewritten.</param> + /// <param name="fieldName">The field name to rewrite.</param> + public FieldToPropertyRewriter(Type type, string fieldName) + : base(type.FullName, fieldName, InstructionHandleResult.None) + { + this.Type = type; + this.FieldName = fieldName; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction)) + return InstructionHandleResult.None; + + string methodPrefix = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld ? "get" : "set"; + MethodReference propertyRef = module.Import(this.Type.GetMethod($"{methodPrefix}_{this.FieldName}")); + cil.Replace(instruction, cil.Create(OpCodes.Call, propertyRef)); + return InstructionHandleResult.Rewritten; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs new file mode 100644 index 00000000..974fcf4c --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs @@ -0,0 +1,88 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites method references from one parent type to another if the signatures match.</summary> + internal class MethodParentRewriter : IInstructionHandler + { + /********* + ** Properties + *********/ + /// <summary>The type whose methods to remap.</summary> + private readonly Type FromType; + + /// <summary>The type with methods to map to.</summary> + private readonly Type ToType; + + /// <summary>Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</summary> + private readonly bool OnlyIfPlatformChanged; + + + /********* + ** Accessors + *********/ + /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> + public string NounPhrase { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fromType">The type whose methods to remap.</param> + /// <param name="toType">The type with methods to map to.</param> + /// <param name="onlyIfPlatformChanged">Whether to only rewrite references if loading the assembly on a different platform than it was compiled on.</param> + public MethodParentRewriter(Type fromType, Type toType, bool onlyIfPlatformChanged = false) + { + this.FromType = fromType; + this.ToType = toType; + this.NounPhrase = $"{fromType.Name} methods"; + this.OnlyIfPlatformChanged = onlyIfPlatformChanged; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + return InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction, platformChanged)) + return InstructionHandleResult.None; + + MethodReference methodRef = (MethodReference)instruction.Operand; + methodRef.DeclaringType = module.Import(this.ToType); + return InstructionHandleResult.Rewritten; + } + + + /********* + ** Protected methods + *********/ + /// <summary>Get whether a CIL instruction matches.</summary> + /// <param name="instruction">The IL instruction.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + protected bool IsMatch(Instruction instruction, bool platformChanged) + { + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + return + methodRef != null + && (platformChanged || !this.OnlyIfPlatformChanged) + && methodRef.DeclaringType.FullName == this.FromType.FullName + && RewriteHelper.HasMatchingSignature(this.ToType, methodRef); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs new file mode 100644 index 00000000..74f2fcdd --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs @@ -0,0 +1,154 @@ +using System; +using Mono.Cecil; +using Mono.Cecil.Cil; +using StardewModdingAPI.Framework.ModLoading.Finders; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters +{ + /// <summary>Rewrites all references to a type.</summary> + internal class TypeReferenceRewriter : TypeFinder + { + /********* + ** Properties + *********/ + /// <summary>The full type name to which to find references.</summary> + private readonly string FromTypeName; + + /// <summary>The new type to reference.</summary> + private readonly Type ToType; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fromTypeFullName">The full type name to which to find references.</param> + /// <param name="toType">The new type to reference.</param> + public TypeReferenceRewriter(string fromTypeFullName, Type toType) + : base(fromTypeFullName, InstructionHandleResult.None) + { + this.FromTypeName = fromTypeFullName; + this.ToType = toType; + } + + /// <summary>Perform the predefined logic for a method if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="method">The method definition containing the instruction.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, MethodDefinition method, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + bool rewritten = false; + + // return type + if (this.IsMatch(method.ReturnType)) + { + method.ReturnType = this.RewriteIfNeeded(module, method.ReturnType); + rewritten = true; + } + + // parameters + foreach (ParameterDefinition parameter in method.Parameters) + { + if (this.IsMatch(parameter.ParameterType)) + { + parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType); + rewritten = true; + } + } + + // generic parameters + for (int i = 0; i < method.GenericParameters.Count; i++) + { + var parameter = method.GenericParameters[i]; + if (this.IsMatch(parameter)) + { + TypeReference newType = this.RewriteIfNeeded(module, parameter); + if (newType != parameter) + method.GenericParameters[i] = new GenericParameter(parameter.Name, newType); + rewritten = true; + } + } + + // local variables + foreach (VariableDefinition variable in method.Body.Variables) + { + if (this.IsMatch(variable.VariableType)) + { + variable.VariableType = this.RewriteIfNeeded(module, variable.VariableType); + rewritten = true; + } + } + + return rewritten + ? InstructionHandleResult.Rewritten + : InstructionHandleResult.None; + } + + /// <summary>Perform the predefined logic for an instruction if applicable.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="cil">The CIL processor.</param> + /// <param name="instruction">The instruction to handle.</param> + /// <param name="assemblyMap">Metadata for mapping assemblies to the current platform.</param> + /// <param name="platformChanged">Whether the mod was compiled on a different platform.</param> + public override InstructionHandleResult Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction, PlatformAssemblyMap assemblyMap, bool platformChanged) + { + if (!this.IsMatch(instruction) && !instruction.ToString().Contains(this.FromTypeName)) + return InstructionHandleResult.None; + + // field reference + FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + if (fieldRef != null) + { + fieldRef.DeclaringType = this.RewriteIfNeeded(module, fieldRef.DeclaringType); + fieldRef.FieldType = this.RewriteIfNeeded(module, fieldRef.FieldType); + } + + // method reference + MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + if (methodRef != null) + { + methodRef.DeclaringType = this.RewriteIfNeeded(module, methodRef.DeclaringType); + methodRef.ReturnType = this.RewriteIfNeeded(module, methodRef.ReturnType); + foreach (var parameter in methodRef.Parameters) + parameter.ParameterType = this.RewriteIfNeeded(module, parameter.ParameterType); + } + + // type reference + if (instruction.Operand is TypeReference typeRef) + { + TypeReference newRef = this.RewriteIfNeeded(module, typeRef); + if (typeRef != newRef) + cil.Replace(instruction, cil.Create(instruction.OpCode, newRef)); + } + + return InstructionHandleResult.Rewritten; + } + + /********* + ** Private methods + *********/ + /// <summary>Get the adjusted type reference if it matches, else the same value.</summary> + /// <param name="module">The assembly module containing the instruction.</param> + /// <param name="type">The type to replace if it matches.</param> + private TypeReference RewriteIfNeeded(ModuleDefinition module, TypeReference type) + { + // root type + if (type.FullName == this.FromTypeName) + return module.Import(this.ToType); + + // generic arguments + if (type is GenericInstanceType genericType) + { + for (int i = 0; i < genericType.GenericArguments.Count; i++) + genericType.GenericArguments[i] = this.RewriteIfNeeded(module, genericType.GenericArguments[i]); + } + + // generic parameters (e.g. constraints) + for (int i = 0; i < type.GenericParameters.Count; i++) + type.GenericParameters[i] = new GenericParameter(this.RewriteIfNeeded(module, type.GenericParameters[i])); + + return type; + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModLoading/Rewriters/Wrappers/SpriteBatchWrapper.cs b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/Wrappers/SpriteBatchWrapper.cs new file mode 100644 index 00000000..7a631f69 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModLoading/Rewriters/Wrappers/SpriteBatchWrapper.cs @@ -0,0 +1,59 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI.Framework.ModLoading.Rewriters.Wrappers +{ + /// <summary>Wraps <see cref="SpriteBatch"/> methods that are incompatible when converting compiled code between MonoGame and XNA.</summary> + internal class SpriteBatchWrapper : SpriteBatch + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SpriteBatchWrapper(GraphicsDevice graphicsDevice) : base(graphicsDevice) { } + + + /**** + ** MonoGame signatures + ****/ + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Linux/Mac.")] + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix? matrix) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, matrix ?? Matrix.Identity); + } + + /**** + ** XNA signatures + ****/ + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] + public new void Begin() + { + base.Begin(); + } + + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] + public new void Begin(SpriteSortMode sortMode, BlendState blendState) + { + base.Begin(sortMode, blendState); + } + + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState); + } + + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect); + } + + [SuppressMessage("ReSharper", "CS0109", Justification = "The 'new' modifier applies when compiled on Windows.")] + public new void Begin(SpriteSortMode sortMode, BlendState blendState, SamplerState samplerState, DepthStencilState depthStencilState, RasterizerState rasterizerState, Effect effect, Matrix transformMatrix) + { + base.Begin(sortMode, blendState, samplerState, depthStencilState, rasterizerState, effect, transformMatrix); + } + } +} diff --git a/src/StardewModdingAPI/Framework/ModRegistry.cs b/src/StardewModdingAPI/Framework/ModRegistry.cs new file mode 100644 index 00000000..8f30d813 --- /dev/null +++ b/src/StardewModdingAPI/Framework/ModRegistry.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reflection; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Tracks the installed mods.</summary> + internal class ModRegistry + { + /********* + ** Properties + *********/ + /// <summary>The registered mod data.</summary> + private readonly List<IModMetadata> Mods = new List<IModMetadata>(); + + /// <summary>The friendly mod names treated as deprecation warning sources (assembly full name => mod name).</summary> + private readonly IDictionary<string, string> ModNamesByAssembly = new Dictionary<string, string>(); + + + /********* + ** Public methods + *********/ + /**** + ** Basic metadata + ****/ + /// <summary>Get metadata for all loaded mods.</summary> + public IEnumerable<IManifest> GetAll() + { + return this.Mods.Select(p => p.Manifest); + } + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + public IManifest Get(string uniqueID) + { + // normalise search ID + if (string.IsNullOrWhiteSpace(uniqueID)) + return null; + uniqueID = uniqueID.Trim(); + + // find match + return this.GetAll().FirstOrDefault(p => +#if SMAPI_1_x + p.UniqueID != null && +#endif + p.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + public bool IsLoaded(string uniqueID) + { + return this.Get(uniqueID) != null; + } + + /**** + ** Mod data + ****/ + /// <summary>Register a mod as a possible source of deprecation warnings.</summary> + /// <param name="metadata">The mod metadata.</param> + public void Add(IModMetadata metadata) + { + this.Mods.Add(metadata); + this.ModNamesByAssembly[metadata.Mod.GetType().Assembly.FullName] = metadata.DisplayName; + } + + /// <summary>Get all enabled mods.</summary> + public IEnumerable<IModMetadata> GetMods() + { + return (from mod in this.Mods select mod); + } + + /// <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> + public string GetModFrom(Type type) + { + // null + if (type == null) + return null; + + // known type + string assemblyName = type.Assembly.FullName; + if (this.ModNamesByAssembly.ContainsKey(assemblyName)) + return this.ModNamesByAssembly[assemblyName]; + + // not found + return null; + } + + /// <summary>Get the friendly name for the closest assembly registered as a source of deprecation warnings.</summary> + /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns> + public string GetModFromStack() + { + // get stack frames + StackTrace stack = new StackTrace(); + StackFrame[] frames = stack.GetFrames(); + if (frames == null) + return null; + + // search stack for a source assembly + foreach (StackFrame frame in frames) + { + MethodBase method = frame.GetMethod(); + string name = this.GetModFrom(method.ReflectedType); + if (name != null) + return name; + } + + // no known assembly found + return null; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/DisabledMod.cs b/src/StardewModdingAPI/Framework/Models/DisabledMod.cs new file mode 100644 index 00000000..170fa760 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/DisabledMod.cs @@ -0,0 +1,22 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Metadata about for a mod that should never be loaded.</summary> + internal class DisabledMod + { + /********* + ** Accessors + *********/ + /**** + ** From config + ****/ + /// <summary>The unique mod IDs.</summary> + public string[] ID { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The reason phrase to show in the warning, or <c>null</c> to use the default value.</summary> + /// <example>"this mod is no longer supported or used"</example> + public string ReasonPhrase { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/GitRelease.cs b/src/StardewModdingAPI/Framework/Models/GitRelease.cs new file mode 100644 index 00000000..bc53468f --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/GitRelease.cs @@ -0,0 +1,19 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Metadata about a GitHub release tag.</summary> + internal class GitRelease + { + /********* + ** Accessors + *********/ + /// <summary>The display name.</summary> + [JsonProperty("name")] + public string Name { get; set; } + + /// <summary>The semantic version string.</summary> + [JsonProperty("tag_name")] + public string Tag { get; set; } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Models/Manifest.cs b/src/StardewModdingAPI/Framework/Models/Manifest.cs new file mode 100644 index 00000000..29c3517e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/Manifest.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>A manifest which describes a mod for SMAPI.</summary> + internal class Manifest : IManifest + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>A brief description of the mod.</summary> + public string Description { get; set; } + + /// <summary>The mod author's name.</summary> + public string Author { get; set; } + + /// <summary>The mod version.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion Version { get; set; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion MinimumApiVersion { get; set; } + + /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> + public string EntryDll { get; set; } + + /// <summary>The other mods that must be loaded before this mod.</summary> + [JsonConverter(typeof(SFieldConverter))] + public IManifestDependency[] Dependencies { get; set; } + + /// <summary>The unique mod ID.</summary> + public string UniqueID { get; set; } + +#if SMAPI_1_x + /// <summary>Whether the mod uses per-save config files.</summary> + [Obsolete("Use " + nameof(Mod) + "." + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadConfig) + " instead")] + public bool PerSaveConfigs { get; set; } +#endif + + /// <summary>Any manifest fields which didn't match a valid field.</summary> + [JsonExtensionData] + public IDictionary<string, object> ExtraFields { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs new file mode 100644 index 00000000..67f906e3 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ManifestDependency.cs @@ -0,0 +1,42 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>A mod dependency listed in a mod manifest.</summary> + internal class ManifestDependency : IManifestDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID to require.</summary> + public string UniqueID { get; set; } + + /// <summary>The minimum required version (if any).</summary> + public ISemanticVersion MinimumVersion { get; set; } + +#if !SMAPI_1_x + /// <summary>Whether the dependency must be installed to use the mod.</summary> + public bool IsRequired { get; set; } +#endif + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="uniqueID">The unique mod ID to require.</param> + /// <param name="minimumVersion">The minimum required version (if any).</param> + /// <param name="required">Whether the dependency must be installed to use the mod.</param> + public ManifestDependency(string uniqueID, string minimumVersion +#if !SMAPI_1_x + , bool required = true +#endif + ) + { + this.UniqueID = uniqueID; + this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null; +#if !SMAPI_1_x + this.IsRequired = required; +#endif + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs new file mode 100644 index 00000000..d3a9c533 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibility.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Serialisation; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Metadata about a mod version that SMAPI should assume is compatible or broken, regardless of whether it detects incompatible code.</summary> + internal class ModCompatibility + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod IDs.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ModCompatibilityID[] ID { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The oldest incompatible mod version, or <c>null</c> for all past versions.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion LowerVersion { get; set; } + + /// <summary>The most recent incompatible mod version.</summary> + [JsonConverter(typeof(SFieldConverter))] + public ISemanticVersion UpperVersion { get; set; } + + /// <summary>A label to show to the user instead of <see cref="UpperVersion"/>, when the manifest version differs from the user-facing version.</summary> + public string UpperVersionLabel { get; set; } + + /// <summary>The URLs the user can check for a newer version.</summary> + public string[] UpdateUrls { get; set; } + + /// <summary>The reason phrase to show in the warning, or <c>null</c> to use the default value.</summary> + /// <example>"this version is incompatible with the latest version of the game"</example> + public string ReasonPhrase { get; set; } + + /// <summary>Indicates how SMAPI should consider the mod.</summary> + public ModCompatibilityType Compatibility { get; set; } = ModCompatibilityType.AssumeBroken; + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs new file mode 100644 index 00000000..98e70116 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibilityID.cs @@ -0,0 +1,57 @@ +using System; +using Newtonsoft.Json; + +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Uniquely identifies a mod for compatibility checks.</summary> + internal class ModCompatibilityID + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The mod name to disambiguate non-unique IDs, or <c>null</c> to ignore the mod name.</summary> + public string Name { get; set; } + + /// <summary>The author name to disambiguate non-unique IDs, or <c>null</c> to ignore the author.</summary> + public string Author { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModCompatibilityID() { } + + /// <summary>Construct an instance.</summary> + /// <param name="data">The mod ID or a JSON string matching the <see cref="ModCompatibilityID"/> fields.</param> + public ModCompatibilityID(string data) + { + // JSON can be stuffed into the ID string as a convenience hack to keep JSON mod lists + // formatted readably. The tradeoff is that the format is a bit more magical, but that's + // probably acceptable since players aren't meant to edit it. It's also fairly clear what + // the JSON strings do, if not necessarily how. + if (data.StartsWith("{")) + JsonConvert.PopulateObject(data, this); + else + this.ID = data; + } + + /// <summary>Get whether this ID matches a given mod manifest.</summary> + /// <param name="id">The mod's unique ID, or a substitute ID if it isn't set in the manifest.</param> + /// <param name="manifest">The manifest to check.</param> + public bool Matches(string id, IManifest manifest) + { + return + this.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase) + && ( + this.Author == null + || this.Author.Equals(manifest.Author, StringComparison.InvariantCultureIgnoreCase) + || (manifest.ExtraFields.ContainsKey("Authour") && this.Author.Equals(manifest.ExtraFields["Authour"].ToString(), StringComparison.InvariantCultureIgnoreCase)) + ) + && (this.Name == null || this.Name.Equals(manifest.Name, StringComparison.InvariantCultureIgnoreCase)); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs b/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs new file mode 100644 index 00000000..35edec5e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/ModCompatibilityType.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>Indicates how SMAPI should consider a mod.</summary> + internal enum ModCompatibilityType + { + /// <summary>Assume the mod is not compatible, even if SMAPI doesn't detect any incompatible code.</summary> + AssumeBroken = 0, + + /// <summary>Assume the mod is compatible, even if SMAPI detects incompatible code.</summary> + AssumeCompatible = 1 + } +} diff --git a/src/StardewModdingAPI/Framework/Models/SConfig.cs b/src/StardewModdingAPI/Framework/Models/SConfig.cs new file mode 100644 index 00000000..b2ca4113 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Models/SConfig.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Framework.Models +{ + /// <summary>The SMAPI configuration settings.</summary> + internal class SConfig + { + /******** + ** Accessors + ********/ + /// <summary>Whether to enable development features.</summary> + public bool DeveloperMode { get; set; } + + /// <summary>Whether to check if a newer version of SMAPI is available on startup.</summary> + public bool CheckForUpdates { get; set; } = true; + + /// <summary>Whether SMAPI should log more information about the game context.</summary> + public bool VerboseLogging { get; set; } = false; + + /// <summary>A list of mod versions which should be considered compatible or incompatible regardless of whether SMAPI detects incompatible code.</summary> + public ModCompatibility[] ModCompatibility { get; set; } + + /// <summary>A list of mods which should be considered obsolete and not loaded.</summary> + public DisabledMod[] DisabledMods { get; set; } + } +} diff --git a/src/StardewModdingAPI/Framework/Monitor.cs b/src/StardewModdingAPI/Framework/Monitor.cs new file mode 100644 index 00000000..c2c3a689 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Monitor.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using StardewModdingAPI.Framework.Logging; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Encapsulates monitoring and logic for a given module.</summary> + internal class Monitor : IMonitor + { + /********* + ** Properties + *********/ + /// <summary>The name of the module which logs messages using this instance.</summary> + private readonly string Source; + + /// <summary>Manages access to the console output.</summary> + private readonly ConsoleInterceptionManager ConsoleManager; + + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> + private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); + + /// <summary>The console text color for each log level.</summary> + private static readonly IDictionary<LogLevel, ConsoleColor> Colors = Monitor.GetConsoleColorScheme(); + + /// <summary>Propagates notification that SMAPI should exit.</summary> + private readonly CancellationTokenSource ExitTokenSource; + + + /********* + ** Accessors + *********/ + /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> + public bool IsExiting => this.ExitTokenSource.IsCancellationRequested; + +#if !SMAPI_1_x + /// <summary>Whether to show the full log stamps (with time/level/logger) in the console. If false, shows a simplified stamp with only the logger.</summary> + internal bool ShowFullStampInConsole { get; set; } +#endif + + /// <summary>Whether to show trace messages in the console.</summary> + internal bool ShowTraceInConsole { get; set; } + + /// <summary>Whether to write anything to the console. This should be disabled if no console is available.</summary> + internal bool WriteToConsole { get; set; } = true; + + /// <summary>Whether to write anything to the log file. This should almost always be enabled.</summary> + internal bool WriteToFile { get; set; } = true; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="source">The name of the module which logs messages using this instance.</param> + /// <param name="consoleManager">Manages access to the console output.</param> + /// <param name="logFile">The log file to which to write messages.</param> + /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> + public Monitor(string source, ConsoleInterceptionManager consoleManager, LogFileManager logFile, CancellationTokenSource exitTokenSource) + { + // validate + if (string.IsNullOrWhiteSpace(source)) + throw new ArgumentException("The log source cannot be empty."); + + // initialise + this.Source = source; + this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); + this.ConsoleManager = consoleManager; + this.ExitTokenSource = exitTokenSource; + } + + /// <summary>Log a message for the player or developer.</summary> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public void Log(string message, LogLevel level = LogLevel.Debug) + { + this.LogImpl(this.Source, message, level, Monitor.Colors[level]); + } + + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="reason">The reason for the shutdown.</param> + public void ExitGameImmediately(string reason) + { + this.LogFatal($"{this.Source} requested an immediate game shutdown: {reason}"); + this.ExitTokenSource.Cancel(); + } + +#if !SMAPI_1_x + /// <summary>Write a newline to the console and log file.</summary> + internal void Newline() + { + if (this.WriteToConsole) + this.ConsoleManager.ExclusiveWriteWithoutInterception(Console.WriteLine); + if (this.WriteToFile) + this.LogFile.WriteLine(""); + } +#endif + +#if SMAPI_1_x + /// <summary>Log a message for the player or developer, using the specified console color.</summary> + /// <param name="source">The name of the mod logging the message.</param> + /// <param name="message">The message to log.</param> + /// <param name="color">The console color.</param> + /// <param name="level">The log level.</param> + [Obsolete("This method is provided for backwards compatibility and otherwise should not be used. Use " + nameof(Monitor) + "." + nameof(Monitor.Log) + " instead.")] + internal void LegacyLog(string source, string message, ConsoleColor color, LogLevel level = LogLevel.Debug) + { + this.LogImpl(source, message, level, color); + } +#endif + + + /********* + ** Private methods + *********/ + /// <summary>Log a fatal error message.</summary> + /// <param name="message">The message to log.</param> + private void LogFatal(string message) + { + this.LogImpl(this.Source, message, LogLevel.Error, ConsoleColor.White, background: ConsoleColor.Red); + } + + /// <summary>Write a message line to the log.</summary> + /// <param name="source">The name of the mod logging the message.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log level.</param> + /// <param name="color">The console foreground color.</param> + /// <param name="background">The console background color (or <c>null</c> to leave it as-is).</param> + private void LogImpl(string source, string message, LogLevel level, ConsoleColor color, ConsoleColor? background = null) + { + // generate message + string levelStr = level.ToString().ToUpper().PadRight(Monitor.MaxLevelLength); + + string fullMessage = $"[{DateTime.Now:HH:mm:ss} {levelStr} {source}] {message}"; +#if !SMAPI_1_x + string consoleMessage = this.ShowFullStampInConsole ? fullMessage : $"[{source}] {message}"; +#else + string consoleMessage = fullMessage; +#endif + + // write to console + if (this.WriteToConsole && (this.ShowTraceInConsole || level != LogLevel.Trace)) + { + this.ConsoleManager.ExclusiveWriteWithoutInterception(() => + { + if (this.ConsoleManager.SupportsColor) + { + if (background.HasValue) + Console.BackgroundColor = background.Value; + Console.ForegroundColor = color; + Console.WriteLine(consoleMessage); + Console.ResetColor(); + } + else + Console.WriteLine(consoleMessage); + }); + } + + // write to log file + if (this.WriteToFile) + this.LogFile.WriteLine(fullMessage); + } + + /// <summary>Get the color scheme to use for the current console.</summary> + private static IDictionary<LogLevel, ConsoleColor> GetConsoleColorScheme() + { +#if !SMAPI_1_x + // scheme for dark console background + if (Monitor.IsDark(Console.BackgroundColor)) + { +#endif + return new Dictionary<LogLevel, ConsoleColor> + { + [LogLevel.Trace] = ConsoleColor.DarkGray, + [LogLevel.Debug] = ConsoleColor.DarkGray, + [LogLevel.Info] = ConsoleColor.White, + [LogLevel.Warn] = ConsoleColor.Yellow, + [LogLevel.Error] = ConsoleColor.Red, + [LogLevel.Alert] = ConsoleColor.Magenta + }; +#if !SMAPI_1_x + } + + // scheme for light console background + return new Dictionary<LogLevel, ConsoleColor> + { + [LogLevel.Trace] = ConsoleColor.DarkGray, + [LogLevel.Debug] = ConsoleColor.DarkGray, + [LogLevel.Info] = ConsoleColor.Black, + [LogLevel.Warn] = ConsoleColor.DarkYellow, + [LogLevel.Error] = ConsoleColor.Red, + [LogLevel.Alert] = ConsoleColor.DarkMagenta + }; +#endif + } + + /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary> + /// <param name="color">The color to check.</param> + private static bool IsDark(ConsoleColor color) + { + switch (color) + { + case ConsoleColor.Black: + case ConsoleColor.Blue: + case ConsoleColor.DarkBlue: + case ConsoleColor.DarkRed: + case ConsoleColor.Red: + return true; + + default: + return false; + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs b/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs new file mode 100644 index 00000000..30faca37 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/CacheEntry.cs @@ -0,0 +1,30 @@ +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A cached member reflection result.</summary> + internal struct CacheEntry + { + /********* + ** Accessors + *********/ + /// <summary>Whether the lookup found a valid match.</summary> + public bool IsValid; + + /// <summary>The reflection data for this member (or <c>null</c> if invalid).</summary> + public MemberInfo MemberInfo; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="isValid">Whether the lookup found a valid match.</param> + /// <param name="memberInfo">The reflection data for this member (or <c>null</c> if invalid).</param> + public CacheEntry(bool isValid, MemberInfo memberInfo) + { + this.IsValid = isValid; + this.MemberInfo = memberInfo; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs new file mode 100644 index 00000000..0bf45969 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateField.cs @@ -0,0 +1,93 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private field obtained through reflection.</summary> + /// <typeparam name="TValue">The field value type.</typeparam> + internal class PrivateField<TValue> : IPrivateField<TValue> + { + /********* + ** Properties + *********/ + /// <summary>The type that has the field.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance field (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.FieldInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public FieldInfo FieldInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the field.</param> + /// <param name="obj">The object that has the instance field (if applicable).</param> + /// <param name="field">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="field"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception> + public PrivateField(Type parentType, object obj, FieldInfo field, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (field == null) + throw new ArgumentNullException(nameof(field)); + if (isStatic && obj != null) + throw new ArgumentException("A static field cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static field must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.FieldInfo = field; + } + + /// <summary>Get the field value.</summary> + public TValue GetValue() + { + try + { + return (TValue)this.FieldInfo.GetValue(this.Parent); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the private {this.DisplayName} field from {this.FieldInfo.FieldType.FullName} to {typeof(TValue).FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't get the value of the private {this.DisplayName} field", ex); + } + } + + /// <summary>Set the field value.</summary> + //// <param name="value">The value to set.</param> + public void SetValue(TValue value) + { + try + { + this.FieldInfo.SetValue(this.Parent, value); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't assign the private {this.DisplayName} field a {typeof(TValue).FullName} value, must be compatible with {this.FieldInfo.FieldType.FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't set the value of the private {this.DisplayName} field", ex); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs new file mode 100644 index 00000000..ba2374f4 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateMethod.cs @@ -0,0 +1,99 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private method obtained through reflection.</summary> + internal class PrivateMethod : IPrivateMethod + { + /********* + ** Properties + *********/ + /// <summary>The type that has the method.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance method (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.MethodInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public MethodInfo MethodInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the method.</param> + /// <param name="obj">The object that has the instance method(if applicable).</param> + /// <param name="method">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="method"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static method, or not null for a static method.</exception> + public PrivateMethod(Type parentType, object obj, MethodInfo method, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (method == null) + throw new ArgumentNullException(nameof(method)); + if (isStatic && obj != null) + throw new ArgumentException("A static method cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static method must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.MethodInfo = method; + } + + /// <summary>Invoke the method.</summary> + /// <typeparam name="TValue">The return type.</typeparam> + /// <param name="arguments">The method arguments to pass in.</param> + public TValue Invoke<TValue>(params object[] arguments) + { + // invoke method + object result; + try + { + result = this.MethodInfo.Invoke(this.Parent, arguments); + } + catch (Exception ex) + { + throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + } + + // cast return value + try + { + return (TValue)result; + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the return value of the private {this.DisplayName} method from {this.MethodInfo.ReturnType.FullName} to {typeof(TValue).FullName}."); + } + } + + /// <summary>Invoke the method.</summary> + /// <param name="arguments">The method arguments to pass in.</param> + public void Invoke(params object[] arguments) + { + // invoke method + try + { + this.MethodInfo.Invoke(this.Parent, arguments); + } + catch (Exception ex) + { + throw new Exception($"Couldn't invoke the private {this.DisplayName} field", ex); + } + } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs b/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs new file mode 100644 index 00000000..08204b7e --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/PrivateProperty.cs @@ -0,0 +1,93 @@ +using System; +using System.Reflection; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>A private property obtained through reflection.</summary> + /// <typeparam name="TValue">The property value type.</typeparam> + internal class PrivateProperty<TValue> : IPrivateProperty<TValue> + { + /********* + ** Properties + *********/ + /// <summary>The type that has the field.</summary> + private readonly Type ParentType; + + /// <summary>The object that has the instance field (if applicable).</summary> + private readonly object Parent; + + /// <summary>The display name shown in error messages.</summary> + private string DisplayName => $"{this.ParentType.FullName}::{this.PropertyInfo.Name}"; + + + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + public PropertyInfo PropertyInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parentType">The type that has the field.</param> + /// <param name="obj">The object that has the instance field (if applicable).</param> + /// <param name="property">The reflection metadata.</param> + /// <param name="isStatic">Whether the field is static.</param> + /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="property"/> is null.</exception> + /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception> + public PrivateProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) + { + // validate + if (parentType == null) + throw new ArgumentNullException(nameof(parentType)); + if (property == null) + throw new ArgumentNullException(nameof(property)); + if (isStatic && obj != null) + throw new ArgumentException("A static property cannot have an object instance."); + if (!isStatic && obj == null) + throw new ArgumentException("A non-static property must have an object instance."); + + // save + this.ParentType = parentType; + this.Parent = obj; + this.PropertyInfo = property; + } + + /// <summary>Get the property value.</summary> + public TValue GetValue() + { + try + { + return (TValue)this.PropertyInfo.GetValue(this.Parent); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't convert the private {this.DisplayName} property from {this.PropertyInfo.PropertyType.FullName} to {typeof(TValue).FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't get the value of the private {this.DisplayName} property", ex); + } + } + + /// <summary>Set the property value.</summary> + //// <param name="value">The value to set.</param> + public void SetValue(TValue value) + { + try + { + this.PropertyInfo.SetValue(this.Parent, value); + } + catch (InvalidCastException) + { + throw new InvalidCastException($"Can't assign the private {this.DisplayName} property a {typeof(TValue).FullName} value, must be compatible with {this.PropertyInfo.PropertyType.FullName}."); + } + catch (Exception ex) + { + throw new Exception($"Couldn't set the value of the private {this.DisplayName} property", ex); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Reflection/Reflector.cs b/src/StardewModdingAPI/Framework/Reflection/Reflector.cs new file mode 100644 index 00000000..5c2d90fa --- /dev/null +++ b/src/StardewModdingAPI/Framework/Reflection/Reflector.cs @@ -0,0 +1,276 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.Caching; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>Provides helper methods for accessing private game code.</summary> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + internal class Reflector + { + /********* + ** Properties + *********/ + /// <summary>The cached fields and methods found via reflection.</summary> + private readonly MemoryCache Cache = new MemoryCache(typeof(Reflector).FullName); + + /// <summary>The sliding cache expiration time.</summary> + private readonly TimeSpan SlidingCacheExpiry = TimeSpan.FromMinutes(5); + + + /********* + ** Public methods + *********/ + /**** + ** Fields + ****/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns> + public IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance field from a null object."); + + // get field from hierarchy + IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && field == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance field."); + return field; + } + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true) + { + // get field from hierarchy + IPrivateField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && field == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static field."); + return field; + } + + /**** + ** Properties + ****/ + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance property from a null object."); + + // get property from hierarchy + IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && property == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance property."); + return property; + } + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + public IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true) + { + // get field from hierarchy + IPrivateProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && property == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static property."); + return property; + } + + /**** + ** Methods + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true) + { + // validate + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + + // get method from hierarchy + IPrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic); + if (required && method == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method."); + return method; + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true) + { + // get method from hierarchy + IPrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static); + if (required && method == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method."); + return method; + } + + /**** + ** Methods by signature + ****/ + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(object obj, string name, Type[] argumentTypes, bool required = true) + { + // validate parent + if (obj == null) + throw new ArgumentNullException(nameof(obj), "Can't get a private instance method from a null object."); + + // get method from hierarchy + PrivateMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic, argumentTypes); + if (required && method == null) + throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a private '{name}' instance method with that signature."); + return method; + } + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + public IPrivateMethod GetPrivateMethod(Type type, string name, Type[] argumentTypes, bool required = true) + { + // get field from hierarchy + PrivateMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Static, argumentTypes); + if (required && method == null) + throw new InvalidOperationException($"The {type.FullName} object doesn't have a private '{name}' static method with that signature."); + return method; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a field from the type hierarchy.</summary> + /// <typeparam name="TValue">The expected field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of field to find.</param> + private IPrivateField<TValue> GetFieldFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + FieldInfo field = this.GetCached<FieldInfo>($"field::{isStatic}::{type.FullName}::{name}", () => + { + FieldInfo fieldInfo = null; + for (; type != null && fieldInfo == null; type = type.BaseType) + fieldInfo = type.GetField(name, bindingFlags); + return fieldInfo; + }); + + return field != null + ? new PrivateField<TValue>(type, obj, field, isStatic) + : null; + } + + /// <summary>Get a property from the type hierarchy.</summary> + /// <typeparam name="TValue">The expected property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of property to find.</param> + private IPrivateProperty<TValue> GetPropertyFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + PropertyInfo property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () => + { + PropertyInfo propertyInfo = null; + for (; type != null && propertyInfo == null; type = type.BaseType) + propertyInfo = type.GetProperty(name, bindingFlags); + return propertyInfo; + }); + + return property != null + ? new PrivateProperty<TValue>(type, obj, property, isStatic) + : null; + } + + /// <summary>Get a method from the type hierarchy.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The method name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> + private IPrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () => + { + MethodInfo methodInfo = null; + for (; type != null && methodInfo == null; type = type.BaseType) + methodInfo = type.GetMethod(name, bindingFlags); + return methodInfo; + }); + + return method != null + ? new PrivateMethod(type, obj, method, isStatic: bindingFlags.HasFlag(BindingFlags.Static)) + : null; + } + + /// <summary>Get a method from the type hierarchy.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The method name.</param> + /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> + /// <param name="argumentTypes">The argument types of the method signature to find.</param> + private PrivateMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes) + { + bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); + MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}({string.Join(",", argumentTypes.Select(p => p.FullName))})", () => + { + MethodInfo methodInfo = null; + for (; type != null && methodInfo == null; type = type.BaseType) + methodInfo = type.GetMethod(name, bindingFlags, null, argumentTypes, null); + return methodInfo; + }); + return method != null + ? new PrivateMethod(type, obj, method, isStatic) + : null; + } + + /// <summary>Get a method or field through the cache.</summary> + /// <typeparam name="TMemberInfo">The expected <see cref="MemberInfo"/> type.</typeparam> + /// <param name="key">The cache key.</param> + /// <param name="fetch">Fetches a new value to cache.</param> + private TMemberInfo GetCached<TMemberInfo>(string key, Func<TMemberInfo> fetch) where TMemberInfo : MemberInfo + { + // get from cache + if (this.Cache.Contains(key)) + { + CacheEntry entry = (CacheEntry)this.Cache[key]; + return entry.IsValid + ? (TMemberInfo)entry.MemberInfo + : default(TMemberInfo); + } + + // fetch & cache new value + TMemberInfo result = fetch(); + CacheEntry cacheEntry = new CacheEntry(result != null, result); + this.Cache.Add(key, cacheEntry, new CacheItemPolicy { SlidingExpiration = this.SlidingCacheExpiry }); + return result; + } + } +} diff --git a/src/StardewModdingAPI/Framework/RequestExitDelegate.cs b/src/StardewModdingAPI/Framework/RequestExitDelegate.cs new file mode 100644 index 00000000..12d0ea0c --- /dev/null +++ b/src/StardewModdingAPI/Framework/RequestExitDelegate.cs @@ -0,0 +1,7 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>A delegate which requests that SMAPI immediately exit the game. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="module">The module which requested an immediate exit.</param> + /// <param name="reason">The reason provided for the shutdown.</param> + internal delegate void RequestExitDelegate(string module, string reason); +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs new file mode 100644 index 00000000..33403841 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -0,0 +1,516 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Metadata; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's implementation of the game's content manager which lets it raise content events.</summary> + internal class SContentManager : LocalizedContentManager + { + /********* + ** Properties + *********/ + /// <summary>The possible directory separator characters in an asset key.</summary> + private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); + + /// <summary>The preferred directory separator chaeacter in an asset key.</summary> + private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>The underlying content manager's asset cache.</summary> + private readonly IDictionary<string, object> Cache; + + /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary> + private readonly Func<string, string> NormaliseAssetNameForPlatform; + + /// <summary>The private <see cref="LocalizedContentManager"/> method which generates the locale portion of an asset name.</summary> + private readonly IPrivateMethod GetKeyLocale; + + /// <summary>The language codes used in asset keys.</summary> + private readonly IDictionary<string, LanguageCode> KeyLocales; + + /// <summary>Provides metadata for core game assets.</summary> + private readonly CoreAssets CoreAssets; + + /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> + private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); + + /// <summary>A lookup of the content managers which loaded each asset.</summary> + private readonly IDictionary<string, HashSet<ContentManager>> AssetLoaders = new Dictionary<string, HashSet<ContentManager>>(); + + + /********* + ** Accessors + *********/ + /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + internal IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>(); + + /// <summary>Interceptors which edit matching assets after they're loaded.</summary> + internal IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>(); + + /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> + public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="serviceProvider">The service provider to use to locate services.</param> + /// <param name="rootDirectory">The root directory to search for content.</param> + /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="languageCodeOverride">The current language code for which to localise content.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + public SContentManager(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, string languageCodeOverride, IMonitor monitor) + : base(serviceProvider, rootDirectory, currentCulture, languageCodeOverride) + { + // validate + if (monitor == null) + throw new ArgumentNullException(nameof(monitor)); + + // initialise + var reflection = new Reflector(); + this.Monitor = monitor; + + // get underlying fields for interception + this.Cache = reflection.GetPrivateField<Dictionary<string, object>>(this, "loadedAssets").GetValue(); + this.GetKeyLocale = reflection.GetPrivateMethod(this, "languageCode"); + + // get asset key normalisation logic + if (Constants.TargetPlatform == Platform.Windows) + { + IPrivateMethod method = reflection.GetPrivateMethod(typeof(TitleContainer), "GetCleanPath"); + this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path); + } + else + this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic + + // get asset data + this.CoreAssets = new CoreAssets(this.NormaliseAssetName); + this.KeyLocales = this.GetKeyLocales(reflection); + } + + /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseAssetName"/> instead.</summary> + /// <param name="path">The file path to normalise.</param> + public string NormalisePathSeparators(string path) + { + string[] parts = path.Split(SContentManager.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); + string normalised = string.Join(SContentManager.PreferredPathSeparator, parts); + if (path.StartsWith(SContentManager.PreferredPathSeparator)) + normalised = SContentManager.PreferredPathSeparator + normalised; // keep root slash + return normalised; + } + + /// <summary>Normalise an asset name so it's consistent with the underlying cache.</summary> + /// <param name="assetName">The asset key.</param> + public string NormaliseAssetName(string assetName) + { + assetName = this.NormalisePathSeparators(assetName); + if (assetName.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase)) + return assetName.Substring(0, assetName.Length - 4); + return this.NormaliseAssetNameForPlatform(assetName); + } + + /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public bool IsLoaded(string assetName) + { + assetName = this.NormaliseAssetName(assetName); + return this.IsNormalisedKeyLoaded(assetName); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.LoadFor<T>(assetName, this); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="instance">The content manager instance for which to load the asset.</param> + public T LoadFor<T>(string assetName, ContentManager instance) + { + assetName = this.NormaliseAssetName(assetName); + + // skip if already loaded + if (this.IsNormalisedKeyLoaded(assetName)) + { + this.TrackAssetLoader(assetName, instance); + return base.Load<T>(assetName); + } + + // load asset + T data; + if (this.AssetsBeingLoaded.Contains(assetName)) + { + this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); + this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); + data = base.Load<T>(assetName); + } + else + { + data = this.AssetsBeingLoaded.Track(assetName, () => + { + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, typeof(T), this.NormaliseAssetName); + IAssetData asset = this.ApplyLoader<T>(info) ?? new AssetDataForObject(info, base.Load<T>(assetName), this.NormaliseAssetName); + asset = this.ApplyEditors<T>(info, asset); + return (T)asset.Data; + }); + } + + // update cache & return data + this.Cache[assetName] = data; + this.TrackAssetLoader(assetName, instance); + return data; + } + + /// <summary>Inject an asset into the cache.</summary> + /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="value">The asset value.</param> + public void Inject<T>(string assetName, T value) + { + assetName = this.NormaliseAssetName(assetName); + this.Cache[assetName] = value; + this.TrackAssetLoader(assetName, this); + } + + /// <summary>Get the current content locale.</summary> + public string GetLocale() + { + return this.GetKeyLocale.Invoke<string>(); + } + + /// <summary>Get the cached asset keys.</summary> + public IEnumerable<string> GetAssetKeys() + { + IEnumerable<string> GetAllAssetKeys() + { + foreach (string cacheKey in this.Cache.Keys) + { + this.ParseCacheKey(cacheKey, out string assetKey, out string _); + yield return assetKey; + } + } + + return GetAllAssetKeys().Distinct(); + } + + /// <summary>Purge assets from the cache that match one of the interceptors.</summary> + /// <param name="editors">The asset editors for which to purge matching assets.</param> + /// <param name="loaders">The asset loaders for which to purge matching assets.</param> + /// <returns>Returns whether any cache entries were invalidated.</returns> + public bool InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders) + { + if (!editors.Any() && !loaders.Any()) + return false; + + // get CanEdit/Load methods + MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit)); + MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad)); + + // invalidate matching keys + return this.InvalidateCache((assetName, assetType) => + { + // get asset metadata + IAssetInfo info = new AssetInfo(this.GetLocale(), assetName, assetType, this.NormaliseAssetName); + + // check loaders + MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(assetType); + if (loaders.Any(loader => (bool)canLoadGeneric.Invoke(loader, new object[] { info }))) + return true; + + // check editors + MethodInfo canEditGeneric = canEdit.MakeGenericMethod(assetType); + return editors.Any(editor => (bool)canEditGeneric.Invoke(editor, new object[] { info })); + }); + } + + /// <summary>Purge matched assets from the cache.</summary> + /// <param name="predicate">Matches the asset keys to invalidate.</param> + /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> + /// <returns>Returns whether any cache entries were invalidated.</returns> + public bool InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) + { + // find matching asset keys + HashSet<string> purgeCacheKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> purgeAssetKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string cacheKey in this.Cache.Keys) + { + this.ParseCacheKey(cacheKey, out string assetKey, out _); + Type type = this.Cache[cacheKey].GetType(); + if (predicate(assetKey, type)) + { + purgeAssetKeys.Add(assetKey); + purgeCacheKeys.Add(cacheKey); + } + } + + // purge assets + foreach (string key in purgeCacheKeys) + { + if (dispose && this.Cache[key] is IDisposable disposable) + disposable.Dispose(); + this.Cache.Remove(key); + this.AssetLoaders.Remove(key); + } + + // reload core game assets + int reloaded = 0; + foreach (string key in purgeAssetKeys) + { + if (this.CoreAssets.ReloadForKey(this, key)) + reloaded++; + } + + // report result + if (purgeCacheKeys.Any()) + { + this.Monitor.Log($"Invalidated {purgeCacheKeys.Count} cache entries for {purgeAssetKeys.Count} asset keys: {string.Join(", ", purgeCacheKeys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace); + return true; + } + this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); + return false; + } + + /// <summary>Dispose assets for the given content manager shim.</summary> + /// <param name="shim">The content manager whose assets to dispose.</param> + internal void DisposeFor(ContentManagerShim shim) + { + this.Monitor.Log($"Content manager '{shim.Name}' disposed, disposing assets that aren't needed by any other asset loader.", LogLevel.Trace); + + foreach (var entry in this.AssetLoaders) + entry.Value.Remove(shim); + this.InvalidateCache((key, type) => !this.AssetLoaders[key].Any(), dispose: true); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalisedAssetName">The normalised asset name.</param> + private bool IsNormalisedKeyLoaded(string normalisedAssetName) + { + return this.Cache.ContainsKey(normalisedAssetName) + || this.Cache.ContainsKey($"{normalisedAssetName}.{this.GetKeyLocale.Invoke<string>()}"); // translated asset + } + + /// <summary>Track that a content manager loaded an asset.</summary> + /// <param name="key">The asset key that was loaded.</param> + /// <param name="manager">The content manager that loaded the asset.</param> + private void TrackAssetLoader(string key, ContentManager manager) + { + if (!this.AssetLoaders.TryGetValue(key, out HashSet<ContentManager> hash)) + hash = this.AssetLoaders[key] = new HashSet<ContentManager>(); + hash.Add(manager); + } + + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> + /// <param name="reflection">Simplifies access to private game code.</param> + private IDictionary<string, LanguageCode> GetKeyLocales(Reflector reflection) + { + // get the private code field directly to avoid changed-code logic + IPrivateField<LanguageCode> codeField = reflection.GetPrivateField<LanguageCode>(typeof(LocalizedContentManager), "_currentLangCode"); + + // remember previous settings + LanguageCode previousCode = codeField.GetValue(); + string previousOverride = this.LanguageCodeOverride; + + // create locale => code map + IDictionary<string, LanguageCode> map = new Dictionary<string, LanguageCode>(StringComparer.InvariantCultureIgnoreCase); + this.LanguageCodeOverride = null; + foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) + { + codeField.SetValue(code); + map[this.GetKeyLocale.Invoke<string>()] = code; + } + + // restore previous settings + codeField.SetValue(previousCode); + this.LanguageCodeOverride = previousOverride; + + return map; + } + + /// <summary>Parse a cache key into its component parts.</summary> + /// <param name="cacheKey">The input cache key.</param> + /// <param name="assetKey">The original asset key.</param> + /// <param name="localeCode">The asset locale code (or <c>null</c> if not localised).</param> + private void ParseCacheKey(string cacheKey, out string assetKey, out string localeCode) + { + // handle localised key + if (!string.IsNullOrWhiteSpace(cacheKey)) + { + int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); + if (lastSepIndex >= 0) + { + string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + if (this.KeyLocales.ContainsKey(suffix)) + { + assetKey = cacheKey.Substring(0, lastSepIndex); + localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); + return; + } + } + } + + // handle simple key + assetKey = cacheKey; + localeCode = null; + } + + /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> + /// <param name="info">The basic asset metadata.</param> + /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> + private IAssetData ApplyLoader<T>(IAssetInfo info) + { + // find matching loaders + var loaders = this.GetInterceptors(this.Loaders) + .Where(entry => + { + try + { + return entry.Value.CanLoad<T>(info); + } + catch (Exception ex) + { + this.Monitor.Log($"{entry.Key.DisplayName} crashed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return false; + } + }) + .ToArray(); + + // validate loaders + if (!loaders.Any()) + return null; + if (loaders.Length > 1) + { + string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray(); + this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); + return null; + } + + // fetch asset from loader + IModMetadata mod = loaders[0].Key; + IAssetLoader loader = loaders[0].Value; + T data; + try + { + data = loader.Load<T>(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + return null; + } + + // validate asset + if (data == null) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Error); + return null; + } + + // return matched asset + return new AssetDataForObject(info, data, this.NormaliseAssetName); + } + + /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + /// <param name="asset">The loaded asset.</param> + private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) + { + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.NormaliseAssetName); + + // edit asset + foreach (var entry in this.GetInterceptors(this.Editors)) + { + // check for match + IModMetadata mod = entry.Key; + IAssetEditor editor = entry.Value; + try + { + if (!editor.CanEdit<T>(info)) + continue; + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // try edit + object prevAsset = asset.Data; + try + { + editor.Edit<T>(asset); + this.Monitor.Log($"{mod.DisplayName} intercepted {info.AssetName}.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log($"{mod.DisplayName} crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + + // validate edit + if (asset.Data == null) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + else if (!(asset.Data is T)) + { + this.Monitor.Log($"{mod.DisplayName} incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + asset = GetNewData(prevAsset); + } + } + + // return result + return asset; + } + + /// <summary>Get all registered interceptors from a list.</summary> + private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries) + { + foreach (var entry in entries) + { + IModMetadata metadata = entry.Key; + IList<T> interceptors = entry.Value; + + // special case if mod is an interceptor + if (metadata.Mod is T modAsInterceptor) + yield return new KeyValuePair<IModMetadata, T>(metadata, modAsInterceptor); + + // registered editors + foreach (T interceptor in interceptors) + yield return new KeyValuePair<IModMetadata, T>(metadata, interceptor); + } + } + + /// <summary>Dispose held resources.</summary> + /// <param name="disposing">Whether the content manager is disposing (rather than finalising).</param> + protected override void Dispose(bool disposing) + { + this.Monitor.Log("Disposing SMAPI's main content manager. It will no longer be usable after this point.", LogLevel.Trace); + base.Dispose(disposing); + } + } +} diff --git a/src/StardewModdingAPI/Framework/SGame.cs b/src/StardewModdingAPI/Framework/SGame.cs new file mode 100644 index 00000000..76c106d7 --- /dev/null +++ b/src/StardewModdingAPI/Framework/SGame.cs @@ -0,0 +1,1474 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; +using Microsoft.Xna.Framework.Input; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Utilities; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Tools; +using xTile.Dimensions; +using xTile.Layers; +#if SMAPI_1_x +using SFarmer = StardewValley.Farmer; +#endif + +namespace StardewModdingAPI.Framework +{ + /// <summary>SMAPI's extension of the game's core <see cref="Game1"/>, used to inject events.</summary> + internal class SGame : Game1 + { + /********* + ** Properties + *********/ + /**** + ** SMAPI state + ****/ + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> + private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second + + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> + private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second + + /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> + /// <remarks>Skipping a few frames ensures the game finishes initialising the world before mods try to change it.</remarks> + private int AfterLoadTimer = 5; + + /// <summary>Whether the game is returning to the menu.</summary> + private bool IsExitingToTitle; + + /// <summary>Whether the game is saving and SMAPI has already raised <see cref="SaveEvents.BeforeSave"/>.</summary> + private bool IsBetweenSaveEvents; + + /**** + ** Game state + ****/ + /// <summary>A record of the buttons pressed as of the previous tick.</summary> + private SButton[] PreviousPressedButtons = new SButton[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 PreviousKeyState; + + /// <summary>A record of the controller state (i.e. the up/down state for each button) as of the previous tick.</summary> + private GamePadState PreviousControllerState; + + /// <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 PreviousMouseState; + + /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> + private Point PreviousMousePosition; + + /// <summary>The window size value at last check.</summary> + private Point PreviousWindowSize; + + /// <summary>The save ID at last check.</summary> + private ulong PreviousSaveID; + + /// <summary>A hash of <see cref="Game1.locations"/> at last check.</summary> + private int PreviousGameLocations; + + /// <summary>A hash of the current location's <see cref="GameLocation.objects"/> at last check.</summary> + private int PreviousLocationObjects; + + /// <summary>The player's inventory at last check.</summary> + private IDictionary<Item, int> PreviousItems; + + /// <summary>The player's combat skill level at last check.</summary> + private int PreviousCombatLevel; + + /// <summary>The player's farming skill level at last check.</summary> + private int PreviousFarmingLevel; + + /// <summary>The player's fishing skill level at last check.</summary> + private int PreviousFishingLevel; + + /// <summary>The player's foraging skill level at last check.</summary> + private int PreviousForagingLevel; + + /// <summary>The player's mining skill level at last check.</summary> + private int PreviousMiningLevel; + + /// <summary>The player's luck skill level at last check.</summary> + private int PreviousLuckLevel; + + /// <summary>The player's location at last check.</summary> + private GameLocation PreviousGameLocation; + + /// <summary>The active game menu at last check.</summary> + private IClickableMenu PreviousActiveMenu; + + /// <summary>The mine level at last check.</summary> + private int PreviousMineLevel; + + /// <summary>The time of day (in 24-hour military format) at last check.</summary> + private int PreviousTime; + +#if SMAPI_1_x + /// <summary>The day of month (1–28) at last check.</summary> + private int PreviousDay; + + /// <summary>The season name (winter, spring, summer, or fall) at last check.</summary> + private string PreviousSeason; + + /// <summary>The year number at last check.</summary> + private int PreviousYear; + + /// <summary>Whether the game was transitioning to a new day at last check.</summary> + private bool PreviousIsNewDay; + + /// <summary>The player character at last check.</summary> + private SFarmer PreviousFarmer; +#endif + + /// <summary>The previous content locale.</summary> + private LocalizedContentManager.LanguageCode? PreviousLocale; + + /// <summary>An index incremented on every tick and reset every 60th tick (0–59).</summary> + private int CurrentUpdateTick; + + /// <summary>Whether this is the very first update tick since the game started.</summary> + private bool FirstUpdate; + + /// <summary>The current game instance.</summary> + private static SGame Instance; + + /**** + ** Private wrappers + ****/ + /// <summary>Simplifies access to private game code.</summary> + private static Reflector Reflection; + + // ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming + /// <summary>Used to access private fields and methods.</summary> + private static List<float> _fpsList => SGame.Reflection.GetPrivateField<List<float>>(typeof(Game1), nameof(_fpsList)).GetValue(); + private static Stopwatch _fpsStopwatch => SGame.Reflection.GetPrivateField<Stopwatch>(typeof(Game1), nameof(SGame._fpsStopwatch)).GetValue(); + private static float _fps + { + set => SGame.Reflection.GetPrivateField<float>(typeof(Game1), nameof(_fps)).SetValue(value); + } + private static Task _newDayTask => SGame.Reflection.GetPrivateField<Task>(typeof(Game1), nameof(_newDayTask)).GetValue(); + private Color bgColor => SGame.Reflection.GetPrivateField<Color>(this, nameof(bgColor)).GetValue(); + public RenderTarget2D screenWrapper => SGame.Reflection.GetPrivateProperty<RenderTarget2D>(this, "screen").GetValue(); // deliberately renamed to avoid an infinite loop + public BlendState lightingBlend => SGame.Reflection.GetPrivateField<BlendState>(this, nameof(lightingBlend)).GetValue(); + private readonly Action drawFarmBuildings = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke(new object[0]); + private readonly Action drawHUD = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawHUD)).Invoke(new object[0]); + private readonly Action drawDialogueBox = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke(new object[0]); + private readonly Action renderScreenBuffer = () => SGame.Reflection.GetPrivateMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke(new object[0]); + // ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming + + + /********* + ** Accessors + *********/ + /// <summary>SMAPI's content manager.</summary> + public SContentManager SContentManager { get; } + + /// <summary>Whether SMAPI should log more information about the game context.</summary> + public bool VerboseLogging { get; set; } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="reflection">Simplifies access to private game code.</param> + internal SGame(IMonitor monitor, Reflector reflection) + { + // initialise + this.Monitor = monitor; + this.FirstUpdate = true; + SGame.Instance = this; + SGame.Reflection = reflection; + + // set XNA option required by Stardew Valley + Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; + + // override content manager + this.Monitor?.Log("Overriding content manager...", LogLevel.Trace); + this.SContentManager = new SContentManager(this.Content.ServiceProvider, this.Content.RootDirectory, Thread.CurrentThread.CurrentUICulture, null, this.Monitor); + this.Content = new ContentManagerShim(this.SContentManager, "SGame.Content"); + Game1.content = new ContentManagerShim(this.SContentManager, "Game1.content"); + reflection.GetPrivateField<LocalizedContentManager>(typeof(Game1), "_temporaryContent").SetValue(new ContentManagerShim(this.SContentManager, "Game1._temporaryContent")); // regenerate value with new content manager + } + + /**** + ** Intercepted methods & events + ****/ + /// <summary>Constructor a content manager to read XNB files.</summary> + /// <param name="serviceProvider">The service provider to use to locate services.</param> + /// <param name="rootDirectory">The root directory to search for content.</param> + protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) + { + // return default if SMAPI's content manager isn't initialised yet + if (this.SContentManager == null) + { + this.Monitor?.Log("SMAPI's content manager isn't initialised; skipping content manager interception.", LogLevel.Trace); + return base.CreateContentManager(serviceProvider, rootDirectory); + } + + // return single instance if valid + if (serviceProvider != this.Content.ServiceProvider) + throw new InvalidOperationException("SMAPI uses a single content manager internally. You can't get a new content manager with a different service provider."); + if (rootDirectory != this.Content.RootDirectory) + throw new InvalidOperationException($"SMAPI uses a single content manager internally. You can't get a new content manager with a different root directory (current is {this.Content.RootDirectory}, requested {rootDirectory})."); + return new ContentManagerShim(this.SContentManager, "(generated instance)"); + } + + /// <summary>The method called when the game is updating its state. This happens roughly 60 times per second.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + protected override void Update(GameTime gameTime) + { + try + { + /********* + ** Skip conditions + *********/ + // SMAPI exiting, stop processing game updates + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace); + return; + } + + // While a background new-day task is in progress, the game skips its own update logic + // and defers to the XNA Update method. Running mod code in parallel to the background + // update is risky, because data changes can conflict (e.g. collection changed during + // enumeration errors) and data may change unexpectedly from one mod instruction to the + // next. + // + // Therefore we can just run Game1.Update here without raising any SMAPI events. There's + // a small chance that the task will finish after we defer but before the game checks, + // which means technically events should be raised, but the effects of missing one + // update tick are neglible and not worth the complications of bypassing Game1.Update. + if (SGame._newDayTask != null) + { + base.Update(gameTime); + return; + } + + // While the game is writing to the save file in the background, mods can unexpectedly + // fail since they don't have exclusive access to resources (e.g. collection changed + // during enumeration errors). To avoid problems, events are not invoked while a save + // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is + // opened (since the save hasn't started yet), but all other events should be suppressed. + if (Context.IsSaving) + { + // raise before-save + if (!this.IsBetweenSaveEvents) + { + this.IsBetweenSaveEvents = true; + this.Monitor.Log("Context: before save.", LogLevel.Trace); + SaveEvents.InvokeBeforeSave(this.Monitor); + } + + // suppress non-save events + base.Update(gameTime); + return; + } + if (this.IsBetweenSaveEvents) + { + // raise after-save + this.IsBetweenSaveEvents = false; + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + SaveEvents.InvokeAfterSave(this.Monitor); + TimeEvents.InvokeAfterDayStarted(this.Monitor); + } + + /********* + ** Game loaded events + *********/ + if (this.FirstUpdate) + { + GameEvents.InvokeInitialize(this.Monitor); +#if SMAPI_1_x + GameEvents.InvokeLoadContent(this.Monitor); +#endif + GameEvents.InvokeGameLoaded(this.Monitor); + } + + /********* + ** Locale changed events + *********/ + if (this.PreviousLocale != LocalizedContentManager.CurrentLanguageCode) + { + var oldValue = this.PreviousLocale; + var newValue = LocalizedContentManager.CurrentLanguageCode; + + this.Monitor.Log($"Context: locale set to {newValue}.", LogLevel.Trace); + + if (oldValue != null) + ContentEvents.InvokeAfterLocaleChanged(this.Monitor, oldValue.ToString(), newValue.ToString()); + this.PreviousLocale = newValue; + } + + /********* + ** After load events + *********/ + if (Context.IsSaveLoaded && !SaveGame.IsProcessing /*still loading save*/ && this.AfterLoadTimer >= 0) + { +#if !SMAPI_1_x + if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet) +#endif + this.AfterLoadTimer--; + + if (this.AfterLoadTimer == 0) + { + this.Monitor.Log($"Context: loaded saved game '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + Context.IsWorldReady = true; + + SaveEvents.InvokeAfterLoad(this.Monitor); +#if SMAPI_1_x + PlayerEvents.InvokeLoadedGame(this.Monitor, new EventArgsLoadedGameChanged(Game1.hasLoadedGame)); +#endif + TimeEvents.InvokeAfterDayStarted(this.Monitor); + } + } + + /********* + ** Exit to title events + *********/ + // before exit to title + if (Game1.exitToTitle) + this.IsExitingToTitle = true; + + // after exit to title + if (Context.IsWorldReady && this.IsExitingToTitle && Game1.activeClickableMenu is TitleMenu) + { + this.Monitor.Log("Context: returned to title", LogLevel.Trace); + + this.IsExitingToTitle = false; + this.CleanupAfterReturnToTitle(); + SaveEvents.InvokeAfterReturnToTitle(this.Monitor); + } + + /********* + ** Window events + *********/ + // Here we depend on the game's viewport instead of listening to the Window.Resize + // event because we need to notify mods after the game handles the resize, so the + // game's metadata (like Game1.viewport) are updated. That's a bit complicated + // since the game adds & removes its own handler on the fly. + if (Game1.viewport.Width != this.PreviousWindowSize.X || Game1.viewport.Height != this.PreviousWindowSize.Y) + { + Point size = new Point(Game1.viewport.Width, Game1.viewport.Height); + GraphicsEvents.InvokeResize(this.Monitor); + this.PreviousWindowSize = size; + } + + /********* + ** Input events (if window has focus) + *********/ + if (Game1.game1.IsActive) + { + // get latest state + KeyboardState keyState; + GamePadState controllerState; + MouseState mouseState; + Point mousePosition; + try + { + keyState = Keyboard.GetState(); + controllerState = GamePad.GetState(PlayerIndex.One); + mouseState = Mouse.GetState(); + mousePosition = new Point(Game1.getMouseX(), Game1.getMouseY()); + } + catch (InvalidOperationException) // GetState() may crash for some players if window doesn't have focus but game1.IsActive == true + { + keyState = this.PreviousKeyState; + controllerState = this.PreviousControllerState; + mouseState = this.PreviousMouseState; + mousePosition = this.PreviousMousePosition; + } + + // analyse state + SButton[] currentlyPressedKeys = this.GetPressedButtons(keyState, mouseState, controllerState).ToArray(); + SButton[] previousPressedKeys = this.PreviousPressedButtons; + SButton[] framePressedKeys = currentlyPressedKeys.Except(previousPressedKeys).ToArray(); + SButton[] frameReleasedKeys = previousPressedKeys.Except(currentlyPressedKeys).ToArray(); + bool isClick = framePressedKeys.Contains(SButton.MouseLeft) || (framePressedKeys.Contains(SButton.ControllerA) && !currentlyPressedKeys.Contains(SButton.ControllerX)); + + // get cursor position +#if !SMAPI_1_x + ICursorPosition cursor; + { + // cursor position + Vector2 screenPixels = new Vector2(Game1.getMouseX(), Game1.getMouseY()); + Vector2 tile = new Vector2((Game1.viewport.X + screenPixels.X) / Game1.tileSize, (Game1.viewport.Y + screenPixels.Y) / Game1.tileSize); + Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton + ? tile + : Game1.player.GetGrabTile(); + cursor = new CursorPosition(screenPixels, tile, grabTile); + } +#endif + + // raise button pressed + foreach (SButton button in framePressedKeys) + { +#if !SMAPI_1_x + InputEvents.InvokeButtonPressed(this.Monitor, button, cursor, isClick); +#endif + + // legacy events + if (button.TryGetKeyboard(out Keys key)) + { + if (key != Keys.None) + ControlEvents.InvokeKeyPressed(this.Monitor, key); + } + else if (button.TryGetController(out Buttons controllerButton)) + { + if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) + ControlEvents.InvokeTriggerPressed(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right); + else + ControlEvents.InvokeButtonPressed(this.Monitor, controllerButton); + } + } + + // raise button released + foreach (SButton button in frameReleasedKeys) + { +#if !SMAPI_1_x + bool wasClick = + (button == SButton.MouseLeft && previousPressedKeys.Contains(SButton.MouseLeft)) // released left click + || (button == SButton.ControllerA && previousPressedKeys.Contains(SButton.ControllerA) && !previousPressedKeys.Contains(SButton.ControllerX)); + InputEvents.InvokeButtonReleased(this.Monitor, button, cursor, wasClick); +#endif + + // legacy events + if (button.TryGetKeyboard(out Keys key)) + { + if (key != Keys.None) + ControlEvents.InvokeKeyReleased(this.Monitor, key); + } + else if (button.TryGetController(out Buttons controllerButton)) + { + if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) + ControlEvents.InvokeTriggerReleased(this.Monitor, controllerButton, controllerButton == Buttons.LeftTrigger ? controllerState.Triggers.Left : controllerState.Triggers.Right); + else + ControlEvents.InvokeButtonReleased(this.Monitor, controllerButton); + } + } + + // raise legacy state-changed events + if (keyState != this.PreviousKeyState) + ControlEvents.InvokeKeyboardChanged(this.Monitor, this.PreviousKeyState, keyState); + 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.PreviousControllerState = controllerState; + this.PreviousPressedButtons = currentlyPressedKeys; + } + + /********* + ** Menu events + *********/ + if (Game1.activeClickableMenu != this.PreviousActiveMenu) + { + IClickableMenu previousMenu = this.PreviousActiveMenu; + IClickableMenu newMenu = Game1.activeClickableMenu; + + // log context + if (this.VerboseLogging) + { + if (previousMenu == null) + this.Monitor.Log($"Context: opened menu {newMenu?.GetType().FullName ?? "(none)"}.", LogLevel.Trace); + else if (newMenu == null) + this.Monitor.Log($"Context: closed menu {previousMenu.GetType().FullName}.", LogLevel.Trace); + else + this.Monitor.Log($"Context: changed menu from {previousMenu.GetType().FullName} to {newMenu.GetType().FullName}.", LogLevel.Trace); + } + + // raise menu events + if (newMenu != null) + MenuEvents.InvokeMenuChanged(this.Monitor, previousMenu, newMenu); + else + MenuEvents.InvokeMenuClosed(this.Monitor, previousMenu); + + // update previous menu + // (if the menu was changed in one of the handlers, deliberately defer detection until the next update so mods can be notified of the new menu change) + this.PreviousActiveMenu = newMenu; + } + + /********* + ** World & player events + *********/ + if (Context.IsWorldReady) + { + // raise current location changed + if (Game1.currentLocation != this.PreviousGameLocation) + { + if (this.VerboseLogging) + this.Monitor.Log($"Context: set location to {Game1.currentLocation?.Name ?? "(none)"}.", LogLevel.Trace); + LocationEvents.InvokeCurrentLocationChanged(this.Monitor, this.PreviousGameLocation, Game1.currentLocation); + } + + // raise location list changed + if (this.GetHash(Game1.locations) != this.PreviousGameLocations) + LocationEvents.InvokeLocationsChanged(this.Monitor, Game1.locations); + +#if SMAPI_1_x + // raise player changed + if (Game1.player != this.PreviousFarmer) + PlayerEvents.InvokeFarmerChanged(this.Monitor, this.PreviousFarmer, Game1.player); +#endif + + // raise events that shouldn't be triggered on initial load + if (Game1.uniqueIDForThisGame == this.PreviousSaveID) + { + // raise player leveled up a skill + if (Game1.player.combatLevel != this.PreviousCombatLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Combat, Game1.player.combatLevel); + if (Game1.player.farmingLevel != this.PreviousFarmingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Farming, Game1.player.farmingLevel); + if (Game1.player.fishingLevel != this.PreviousFishingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Fishing, Game1.player.fishingLevel); + if (Game1.player.foragingLevel != this.PreviousForagingLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Foraging, Game1.player.foragingLevel); + if (Game1.player.miningLevel != this.PreviousMiningLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Mining, Game1.player.miningLevel); + if (Game1.player.luckLevel != this.PreviousLuckLevel) + PlayerEvents.InvokeLeveledUp(this.Monitor, EventArgsLevelUp.LevelType.Luck, Game1.player.luckLevel); + + // raise player inventory changed + ItemStackChange[] changedItems = this.GetInventoryChanges(Game1.player.items, this.PreviousItems).ToArray(); + if (changedItems.Any()) + PlayerEvents.InvokeInventoryChanged(this.Monitor, Game1.player.items, changedItems); + + // raise current location's object list changed + if (this.GetHash(Game1.currentLocation.objects) != this.PreviousLocationObjects) + LocationEvents.InvokeOnNewLocationObject(this.Monitor, Game1.currentLocation.objects); + + // raise time changed + if (Game1.timeOfDay != this.PreviousTime) + TimeEvents.InvokeTimeOfDayChanged(this.Monitor, this.PreviousTime, Game1.timeOfDay); +#if SMAPI_1_x + if (Game1.dayOfMonth != this.PreviousDay) + TimeEvents.InvokeDayOfMonthChanged(this.Monitor, this.PreviousDay, Game1.dayOfMonth); + if (Game1.currentSeason != this.PreviousSeason) + TimeEvents.InvokeSeasonOfYearChanged(this.Monitor, this.PreviousSeason, Game1.currentSeason); + if (Game1.year != this.PreviousYear) + TimeEvents.InvokeYearOfGameChanged(this.Monitor, this.PreviousYear, Game1.year); +#endif + + // raise mine level changed + if (Game1.mine != null && Game1.mine.mineLevel != this.PreviousMineLevel) + MineEvents.InvokeMineLevelChanged(this.Monitor, this.PreviousMineLevel, Game1.mine.mineLevel); + } + + // update state + this.PreviousGameLocations = this.GetHash(Game1.locations); + this.PreviousGameLocation = Game1.currentLocation; + this.PreviousCombatLevel = Game1.player.combatLevel; + this.PreviousFarmingLevel = Game1.player.farmingLevel; + this.PreviousFishingLevel = Game1.player.fishingLevel; + this.PreviousForagingLevel = Game1.player.foragingLevel; + this.PreviousMiningLevel = Game1.player.miningLevel; + this.PreviousLuckLevel = Game1.player.luckLevel; + this.PreviousItems = Game1.player.items.Where(n => n != null).ToDictionary(n => n, n => n.Stack); + this.PreviousLocationObjects = this.GetHash(Game1.currentLocation.objects); + this.PreviousTime = Game1.timeOfDay; + this.PreviousMineLevel = Game1.mine?.mineLevel ?? 0; + this.PreviousSaveID = Game1.uniqueIDForThisGame; +#if SMAPI_1_x + this.PreviousFarmer = Game1.player; + this.PreviousDay = Game1.dayOfMonth; + this.PreviousSeason = Game1.currentSeason; + this.PreviousYear = Game1.year; +#endif + } + + /********* + ** Game day transition event (obsolete) + *********/ +#if SMAPI_1_x + if (Game1.newDay != this.PreviousIsNewDay) + { + TimeEvents.InvokeOnNewDay(this.Monitor, this.PreviousDay, Game1.dayOfMonth, Game1.newDay); + this.PreviousIsNewDay = Game1.newDay; + } +#endif + + /********* + ** Game update + *********/ + try + { + base.Update(gameTime); + } + catch (Exception ex) + { + this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + } + + /********* + ** Update events + *********/ + GameEvents.InvokeUpdateTick(this.Monitor); + if (this.FirstUpdate) + { +#if SMAPI_1_x + GameEvents.InvokeFirstUpdateTick(this.Monitor); +#endif + this.FirstUpdate = false; + } + if (this.CurrentUpdateTick % 2 == 0) + GameEvents.InvokeSecondUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 4 == 0) + GameEvents.InvokeFourthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 8 == 0) + GameEvents.InvokeEighthUpdateTick(this.Monitor); + if (this.CurrentUpdateTick % 15 == 0) + GameEvents.InvokeQuarterSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 30 == 0) + GameEvents.InvokeHalfSecondTick(this.Monitor); + if (this.CurrentUpdateTick % 60 == 0) + GameEvents.InvokeOneSecondTick(this.Monitor); + this.CurrentUpdateTick += 1; + if (this.CurrentUpdateTick >= 60) + this.CurrentUpdateTick = 0; + + this.UpdateCrashTimer.Reset(); + } + catch (Exception ex) + { + // log error + this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); + + // exit if irrecoverable + if (!this.UpdateCrashTimer.Decrement()) + this.Monitor.ExitGameImmediately("the game crashed when updating, and SMAPI was unable to recover the game."); + } + } + + /// <summary>The method called to draw everything to the screen.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + protected override void Draw(GameTime gameTime) + { + Context.IsInDrawLoop = true; + try + { + this.DrawImpl(gameTime); + this.DrawCrashTimer.Reset(); + } + catch (Exception ex) + { + // log error + this.Monitor.Log($"An error occured in the overridden draw loop: {ex.GetLogSummary()}", LogLevel.Error); + + // exit if irrecoverable + if (!this.DrawCrashTimer.Decrement()) + { + this.Monitor.ExitGameImmediately("the game crashed when drawing, and SMAPI was unable to recover the game."); + return; + } + + // recover sprite batch + try + { + if (Game1.spriteBatch.IsOpen(SGame.Reflection)) + { + this.Monitor.Log("Recovering sprite batch from error...", LogLevel.Trace); + Game1.spriteBatch.End(); + } + } + catch (Exception innerEx) + { + this.Monitor.Log($"Could not recover sprite batch state: {innerEx.GetLogSummary()}", LogLevel.Error); + } + } + Context.IsInDrawLoop = false; + } + + /// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary> + /// <param name="gameTime">A snapshot of the game timing state.</param> + /// <remarks>This implementation is identical to <see cref="Game1.Draw"/>, except for try..catch around menu draw code, private field references replaced by wrappers, and added events.</remarks> + [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantCast", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] + private void DrawImpl(GameTime gameTime) + { + if (Game1.debugMode) + { + if (SGame._fpsStopwatch.IsRunning) + { + float totalSeconds = (float)SGame._fpsStopwatch.Elapsed.TotalSeconds; + SGame._fpsList.Add(totalSeconds); + while (SGame._fpsList.Count >= 120) + SGame._fpsList.RemoveAt(0); + float num = 0.0f; + foreach (float fps in SGame._fpsList) + num += fps; + SGame._fps = (float)(1.0 / ((double)num / (double)SGame._fpsList.Count)); + } + SGame._fpsStopwatch.Restart(); + } + else + { + if (SGame._fpsStopwatch.IsRunning) + SGame._fpsStopwatch.Reset(); + SGame._fps = 0.0f; + SGame._fpsList.Clear(); + } + if (SGame._newDayTask != null) + { + this.GraphicsDevice.Clear(this.bgColor); + //base.Draw(gameTime); + } + else + { + if ((double)Game1.options.zoomLevel != 1.0) + this.GraphicsDevice.SetRenderTarget(this.screenWrapper); + if (this.IsSaving) + { + this.GraphicsDevice.Clear(this.bgColor); + IClickableMenu activeClickableMenu = Game1.activeClickableMenu; + if (activeClickableMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {activeClickableMenu.GetType().FullName} menu crashed while drawing itself during save. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + activeClickableMenu.exitThisMenu(); + } + Game1.spriteBatch.End(); + } + //base.Draw(gameTime); + this.renderScreenBuffer(); + } + else + { + this.GraphicsDevice.Clear(this.bgColor); + if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + try + { + Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if ((int)Game1.gameMode == 11) + { + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + Game1.spriteBatch.End(); + } + else if (Game1.currentMinigame != null) + { + Game1.currentMinigame.draw(Game1.spriteBatch); + if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.End(); + } + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if (Game1.showingEndOfNightStuff) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.activeClickableMenu != null) + { + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself during end-of-night-stuff. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + } + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else if ((int)Game1.gameMode == 6) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + string str1 = ""; + for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index) + str1 += "."; + string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); + string str3 = str1; + string s = str2 + str3; + string str4 = "..."; + string str5 = str2 + str4; + int widthOfString = SpriteText.getWidthOfString(str5); + int height = 64; + int x = 64; + int y = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - height; + SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str5, -1); + Game1.spriteBatch.End(); + if ((double)Game1.options.zoomLevel != 1.0) + { + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); + } + if (Game1.overlayMenu == null) + return; + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + else + { + Microsoft.Xna.Framework.Rectangle rectangle; + if ((int)Game1.gameMode == 0) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + } + else + { + if (Game1.drawLighting) + { + this.GraphicsDevice.SetRenderTarget(Game1.lightmap); + this.GraphicsDevice.Clear(Color.White * 0.0f); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.name.Equals("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && Game1.currentLocation.isOutdoors ? Game1.outdoorLight : Game1.ambientLight)); + for (int index = 0; index < Game1.currentLightSources.Count; ++index) + { + if (Utility.isOnScreen(Game1.currentLightSources.ElementAt<LightSource>(index).position, (int)((double)Game1.currentLightSources.ElementAt<LightSource>(index).radius * (double)Game1.tileSize * 4.0))) + Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, Game1.currentLightSources.ElementAt<LightSource>(index).position) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), Game1.currentLightSources.ElementAt<LightSource>(index).color, 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), Game1.currentLightSources.ElementAt<LightSource>(index).radius / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + } + Game1.spriteBatch.End(); + this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screenWrapper); + } + if (Game1.bloomDay && Game1.bloom != null) + Game1.bloom.BeginDraw(); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + GraphicsEvents.InvokeOnPreRenderEvent(this.Monitor); + if (Game1.background != null) + Game1.background.draw(Game1.spriteBatch); + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Back").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.currentLocation.drawWater(Game1.spriteBatch); + if (Game1.CurrentEvent == null) + { + foreach (NPC character in Game1.currentLocation.characters) + { + if (!character.swimming && !character.hideShadow && (!character.isInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)character.yJumpOffset / 40f) * character.scale, SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + } + } + else + { + foreach (NPC actor in Game1.CurrentEvent.actors) + { + if (!actor.swimming && !actor.hideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.sprite.spriteHeight <= 16 ? -Game1.pixelZoom : Game1.pixelZoom * 3))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), ((float)Game1.pixelZoom + (float)actor.yJumpOffset / 40f) * actor.scale, SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + } + } + Microsoft.Xna.Framework.Rectangle bounds; + if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation()))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + double x = (double)Game1.shadowTexture.Bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5); + int num3 = 0; + double num4 = 0.0; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.CurrentEvent == null) + { + foreach (NPC character in Game1.currentLocation.characters) + { + if (!character.swimming && !character.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.viewport, character.position + new Vector2((float)(character.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : Game1.pixelZoom * 3)))); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + bounds = Game1.shadowTexture.Bounds; + double x = (double)bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = ((double)Game1.pixelZoom + (double)character.yJumpOffset / 40.0) * (double)character.scale; + int num3 = 0; + double num4 = (double)Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 9.99999997475243E-07; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + } + } + else + { + foreach (NPC actor in Game1.CurrentEvent.actors) + { + if (!actor.swimming && !actor.hideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.viewport, actor.position + new Vector2((float)(actor.sprite.spriteWidth * Game1.pixelZoom) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : Game1.pixelZoom * 3)))); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + bounds = Game1.shadowTexture.Bounds; + double x = (double)bounds.Center.X; + bounds = Game1.shadowTexture.Bounds; + double y = (double)bounds.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = ((double)Game1.pixelZoom + (double)actor.yJumpOffset / 40.0) * (double)actor.scale; + int num3 = 0; + double num4 = (double)Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 9.99999997475243E-07; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + } + } + if (Game1.displayFarmer && !Game1.player.swimming && (!Game1.player.isRidingHorse() && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(Game1.player.getTileLocation()))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D shadowTexture = Game1.shadowTexture; + Vector2 local = Game1.GlobalToLocal(Game1.player.position + new Vector2(32f, 24f)); + Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); + Color white = Color.White; + double num1 = 0.0; + double x = (double)Game1.shadowTexture.Bounds.Center.X; + rectangle = Game1.shadowTexture.Bounds; + double y = (double)rectangle.Center.Y; + Vector2 origin = new Vector2((float)x, (float)y); + double num2 = 4.0 - (!Game1.player.running && !Game1.player.usingTool || Game1.player.FarmerSprite.indexInCurrentAnimation <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[Game1.player.FarmerSprite.CurrentFrame]) * 0.5); + int num3 = 0; + double num4 = (double)Math.Max(0.0001f, (float)((double)Game1.player.getStandingY() / 10000.0 + 0.000110000000859145)) - 9.99999974737875E-05; + spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); + } + if (Game1.displayFarmer) + Game1.player.draw(Game1.spriteBatch); + if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) + Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); + if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + (double)(Game1.tileSize * 3 / 4)) / 10000.0)); + Game1.currentLocation.draw(Game1.spriteBatch); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + string messageToScreen = Game1.currentLocation.currentEvent.messageToScreen; + } + if (Game1.player.ActiveObject == null && (Game1.player.UsingTool || Game1.pickingTool) && (Game1.player.CurrentTool != null && (!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool))) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Name.Equals("Farm")) + this.drawFarmBuildings(); + if (Game1.tvStation >= 0) + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(6 * Game1.tileSize + Game1.tileSize / 4), (float)(2 * Game1.tileSize + Game1.tileSize / 2))), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); + if (Game1.panMode) + { + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / (double)Game1.tileSize) * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Lime * 0.75f); + foreach (Warp warp in Game1.currentLocation.warps) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * Game1.tileSize - Game1.viewport.X, warp.Y * Game1.tileSize - Game1.viewport.Y, Game1.tileSize, Game1.tileSize), Color.Red * 0.75f); + } + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + Game1.currentLocation.drawAboveFrontLayer(Game1.spriteBatch); + Game1.spriteBatch.End(); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.currentLocation.Name.Equals("Farm") && Game1.stats.SeedsSown >= 200U) + { + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 4), (float)(Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize), (float)(2 * Game1.tileSize + Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize), (float)(2 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(3 * Game1.tileSize + Game1.tileSize / 2), (float)(3 * Game1.tileSize))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(5 * Game1.tileSize - Game1.tileSize / 4), (float)Game1.tileSize)), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize), (float)(3 * Game1.tileSize + Game1.tileSize / 6))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + Game1.spriteBatch.Draw(Game1.debrisSpriteSheet, Game1.GlobalToLocal(Game1.viewport, new Vector2((float)(4 * Game1.tileSize + Game1.tileSize / 5), (float)(2 * Game1.tileSize + Game1.tileSize / 3))), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.debrisSpriteSheet, 16, -1, -1)), Color.White); + } + if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.player.ActiveObject.bigCraftable && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) + Game1.drawPlayerHeldObject(Game1.player); + else if (Game1.displayFarmer && Game1.player.ActiveObject != null) + { + if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.position.X, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) + { + Layer layer1 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5); + Size size1 = Game1.viewport.Size; + if (layer1.PickTile(mapDisplayLocation1, size1) != null) + { + Layer layer2 = Game1.currentLocation.Map.GetLayer("Front"); + rectangle = Game1.player.GetBoundingBox(); + Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.position.Y - Game1.tileSize * 3 / 5); + Size size2 = Game1.viewport.Size; + if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) + goto label_127; + } + else + goto label_127; + } + Game1.drawPlayerHeldObject(Game1.player); + } + label_127: + if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.position.Y - Game1.tileSize * 3 / 5), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) + Game1.drawTool(Game1.player); + if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) + { + Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); + Game1.currentLocation.Map.GetLayer("AlwaysFront").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, Game1.pixelZoom); + Game1.mapDisplayDevice.EndScene(); + } + if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) + { + Color color = Color.White; + switch ((int)((double)Game1.toolHold / 600.0) + 2) + { + case 1: + color = Tool.copperColor; + break; + case 2: + color = Tool.steelColor; + break; + case 3: + color = Tool.goldColor; + break; + case 4: + color = Tool.iridiumColor; + break; + } + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, Game1.tileSize / 8 + 4), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : Game1.tileSize), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), Game1.tileSize / 8), color); + } + if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.ignoreDebrisWeather && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10) + { + foreach (WeatherDebris weatherDebris in Game1.debrisWeather) + weatherDebris.draw(Game1.spriteBatch); + } + if (Game1.farmEvent != null) + Game1.farmEvent.draw(Game1.spriteBatch); + if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * Game1.currentLocation.LightLevel); + if (Game1.screenGlow) + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); + if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) + Game1.player.CurrentTool.draw(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / Game1.tileSize), (float)(Game1.viewport.Y / Game1.tileSize))))) + { + for (int index = 0; index < Game1.rainDrops.Length; ++index) + Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White); + } + Game1.spriteBatch.End(); + //base.Draw(gameTime); + Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.eventUp && Game1.currentLocation.currentEvent != null) + { + foreach (NPC actor in Game1.currentLocation.currentEvent.actors) + { + if (actor.isEmoting) + { + Vector2 localPosition = actor.getLocalPosition(Game1.viewport); + localPosition.Y -= (float)(Game1.tileSize * 2 + Game1.pixelZoom * 3); + if (actor.age == 2) + localPosition.Y += (float)(Game1.tileSize / 2); + else if (actor.gender == 1) + localPosition.Y += (float)(Game1.tileSize / 6); + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * (Game1.tileSize / 4) % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * (Game1.tileSize / 4) / Game1.emoteSpriteSheet.Width * (Game1.tileSize / 4), Game1.tileSize / 4, Game1.tileSize / 4)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); + } + } + } + Game1.spriteBatch.End(); + if (Game1.drawLighting) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); + if (Game1.isRaining && Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert)) + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + Game1.spriteBatch.End(); + } + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (Game1.drawGrid) + { + int x1 = -Game1.viewport.X % Game1.tileSize; + float num1 = (float)(-Game1.viewport.Y % Game1.tileSize); + int x2 = x1; + while (x2 < Game1.graphics.GraphicsDevice.Viewport.Width) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x2, (int)num1, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); + x2 += Game1.tileSize; + } + float num2 = num1; + while ((double)num2 < (double)Game1.graphics.GraphicsDevice.Viewport.Height) + { + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x1, (int)num2, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); + num2 += (float)Game1.tileSize; + } + } + if (Game1.currentBillboard != 0) + this.drawBillboard(); + if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && (int)Game1.gameMode == 3) && (!Game1.freezeControls && !Game1.panMode)) + { + GraphicsEvents.InvokeOnPreRenderHudEvent(this.Monitor); + this.drawHUD(); + GraphicsEvents.InvokeOnPostRenderHudEvent(this.Monitor); + } + else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) + Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f); + if (Game1.hudMessages.Count > 0 && (!Game1.eventUp || Game1.isFestival())) + { + for (int i = Game1.hudMessages.Count - 1; i >= 0; --i) + Game1.hudMessages[i].draw(Game1.spriteBatch, i); + } + } + if (Game1.farmEvent != null) + Game1.farmEvent.draw(Game1.spriteBatch); + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox))) + this.drawDialogueBox(); + Viewport viewport; + if (Game1.progressBar) + { + SpriteBatch spriteBatch1 = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + int x1 = (Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2; + rectangle = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea; + int y1 = rectangle.Bottom - Game1.tileSize * 2; + int dialogueWidth = Game1.dialogueWidth; + int height1 = Game1.tileSize / 2; + Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1); + Color lightGray = Color.LightGray; + spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray); + SpriteBatch spriteBatch2 = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int x2 = (viewport.TitleSafeArea.Width - Game1.dialogueWidth) / 2; + viewport = Game1.graphics.GraphicsDevice.Viewport; + rectangle = viewport.TitleSafeArea; + int y2 = rectangle.Bottom - Game1.tileSize * 2; + int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth); + int height2 = Game1.tileSize / 2; + Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2); + Color dimGray = Color.DimGray; + spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); + } + if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) + Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); + if (Game1.isRaining && Game1.currentLocation != null && (Game1.currentLocation.isOutdoors && !(Game1.currentLocation is Desert))) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Blue * 0.2f; + spriteBatch.Draw(staminaRect, bounds, color); + } + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * ((int)Game1.gameMode == 0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } + else if ((double)Game1.flashAlpha > 0.0) + { + if (Game1.options.screenFlash) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.White * Math.Min(1f, Game1.flashAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } + Game1.flashAlpha -= 0.1f; + } + if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) + this.drawDialogueBox(); + foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) + overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0); + if (Game1.debugMode) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + SpriteFont smallFont = Game1.smallFont; + object[] objArray = new object[10]; + int index1 = 0; + string str1; + if (!Game1.panMode) + str1 = "player: " + (object)(Game1.player.getStandingX() / Game1.tileSize) + ", " + (object)(Game1.player.getStandingY() / Game1.tileSize); + else + str1 = ((Game1.getOldMouseX() + Game1.viewport.X) / Game1.tileSize).ToString() + "," + (object)((Game1.getOldMouseY() + Game1.viewport.Y) / Game1.tileSize); + objArray[index1] = (object)str1; + int index2 = 1; + string str2 = " mouseTransparency: "; + objArray[index2] = (object)str2; + int index3 = 2; + float cursorTransparency = Game1.mouseCursorTransparency; + objArray[index3] = (object)cursorTransparency; + int index4 = 3; + string str3 = " mousePosition: "; + objArray[index4] = (object)str3; + int index5 = 4; + int mouseX = Game1.getMouseX(); + objArray[index5] = (object)mouseX; + int index6 = 5; + string str4 = ","; + objArray[index6] = (object)str4; + int index7 = 6; + int mouseY = Game1.getMouseY(); + objArray[index7] = (object)mouseY; + int index8 = 7; + string newLine = Environment.NewLine; + objArray[index8] = (object)newLine; + int index9 = 8; + string str5 = "debugOutput: "; + objArray[index9] = (object)str5; + int index10 = 9; + string debugOutput = Game1.debugOutput; + objArray[index10] = (object)debugOutput; + string text = string.Concat(objArray); + Vector2 position = new Vector2((float)this.GraphicsDevice.Viewport.TitleSafeArea.X, (float)this.GraphicsDevice.Viewport.TitleSafeArea.Y); + Color red = Color.Red; + double num1 = 0.0; + Vector2 zero = Vector2.Zero; + double num2 = 1.0; + int num3 = 0; + double num4 = 0.99999988079071; + spriteBatch.DrawString(smallFont, text, position, red, (float)num1, zero, (float)num2, (SpriteEffects)num3, (float)num4); + } + if (Game1.showKeyHelp) + Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2((float)Game1.tileSize, (float)(Game1.viewport.Height - Game1.tileSize - (Game1.dialogueUp ? Game1.tileSize * 3 + (Game1.isQuestion ? Game1.questionChoices.Count * Game1.tileSize : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + if (Game1.activeClickableMenu != null) + { + try + { + GraphicsEvents.InvokeOnPreRenderGuiEvent(this.Monitor); + Game1.activeClickableMenu.draw(Game1.spriteBatch); + GraphicsEvents.InvokeOnPostRenderGuiEvent(this.Monitor); + } + catch (Exception ex) + { + this.Monitor.Log($"The {Game1.activeClickableMenu.GetType().FullName} menu crashed while drawing itself. SMAPI will force it to exit to avoid crashing the game.\n{ex.GetLogSummary()}", LogLevel.Error); + Game1.activeClickableMenu.exitThisMenu(); + } + } + else if (Game1.farmEvent != null) + Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); + Game1.spriteBatch.End(); + if (Game1.overlayMenu != null) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.overlayMenu.draw(Game1.spriteBatch); + Game1.spriteBatch.End(); + } + + if (GraphicsEvents.HasPostRenderListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + GraphicsEvents.InvokeOnPostRenderEvent(this.Monitor); + Game1.spriteBatch.End(); + } + + this.renderScreenBuffer(); + } + } + } + } + + /**** + ** Methods + ****/ + /// <summary>Perform any cleanup needed when the player unloads a save and returns to the title screen.</summary> + private void CleanupAfterReturnToTitle() + { + Context.IsWorldReady = false; + this.AfterLoadTimer = 5; + this.PreviousSaveID = 0; + } + + /// <summary>Get the buttons pressed in the given stats.</summary> + /// <param name="keyboard">The keyboard state.</param> + /// <param name="mouse">The mouse state.</param> + /// <param name="controller">The controller state.</param> + private IEnumerable<SButton> GetPressedButtons(KeyboardState keyboard, MouseState mouse, GamePadState controller) + { + // keyboard + foreach (Keys key in keyboard.GetPressedKeys()) + yield return key.ToSButton(); + + // mouse + if (mouse.LeftButton == ButtonState.Pressed) + yield return SButton.MouseLeft; + if (mouse.RightButton == ButtonState.Pressed) + yield return SButton.MouseRight; + if (mouse.MiddleButton == ButtonState.Pressed) + yield return SButton.MouseMiddle; + if (mouse.XButton1 == ButtonState.Pressed) + yield return SButton.MouseX1; + if (mouse.XButton2 == ButtonState.Pressed) + yield return SButton.MouseX2; + + // controller + if (controller.IsConnected) + { + if (controller.Buttons.A == ButtonState.Pressed) + yield return SButton.ControllerA; + if (controller.Buttons.B == ButtonState.Pressed) + yield return SButton.ControllerB; + if (controller.Buttons.Back == ButtonState.Pressed) + yield return SButton.ControllerBack; + if (controller.Buttons.BigButton == ButtonState.Pressed) + yield return SButton.BigButton; + if (controller.Buttons.LeftShoulder == ButtonState.Pressed) + yield return SButton.LeftShoulder; + if (controller.Buttons.LeftStick == ButtonState.Pressed) + yield return SButton.LeftStick; + if (controller.Buttons.RightShoulder == ButtonState.Pressed) + yield return SButton.RightShoulder; + if (controller.Buttons.RightStick == ButtonState.Pressed) + yield return SButton.RightStick; + if (controller.Buttons.Start == ButtonState.Pressed) + yield return SButton.ControllerStart; + if (controller.Buttons.X == ButtonState.Pressed) + yield return SButton.ControllerX; + if (controller.Buttons.Y == ButtonState.Pressed) + yield return SButton.ControllerY; + if (controller.DPad.Up == ButtonState.Pressed) + yield return SButton.DPadUp; + if (controller.DPad.Down == ButtonState.Pressed) + yield return SButton.DPadDown; + if (controller.DPad.Left == ButtonState.Pressed) + yield return SButton.DPadLeft; + if (controller.DPad.Right == ButtonState.Pressed) + yield return SButton.DPadRight; + if (controller.Triggers.Left > 0.2f) + yield return SButton.LeftTrigger; + if (controller.Triggers.Right > 0.2f) + yield return SButton.RightTrigger; + } + } + + /// <summary>Get the player inventory changes between two states.</summary> + /// <param name="current">The player's current inventory.</param> + /// <param name="previous">The player's previous inventory.</param> + private IEnumerable<ItemStackChange> GetInventoryChanges(IEnumerable<Item> current, IDictionary<Item, int> previous) + { + current = current.Where(n => n != null).ToArray(); + foreach (Item item in current) + { + // stack size changed + if (previous != null && previous.ContainsKey(item)) + { + if (previous[item] != item.Stack) + yield return new ItemStackChange { Item = item, StackChange = item.Stack - previous[item], ChangeType = ChangeType.StackChange }; + } + + // new item + else + yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added }; + } + + // removed items + if (previous != null) + { + foreach (var entry in previous) + { + if (current.Any(i => i == entry.Key)) + continue; + + yield return new ItemStackChange { Item = entry.Key, StackChange = -entry.Key.Stack, ChangeType = ChangeType.Removed }; + } + } + } + + /// <summary>Get a hash value for an enumeration.</summary> + /// <param name="enumerable">The enumeration of items to hash.</param> + private int GetHash(IEnumerable enumerable) + { + int hash = 0; + foreach (object v in enumerable) + hash ^= v.GetHashCode(); + return hash; + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs new file mode 100644 index 00000000..3193aa3c --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/JsonHelper.cs @@ -0,0 +1,96 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Xna.Framework.Input; +using Newtonsoft.Json; +using StardewModdingAPI.Utilities; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + internal class JsonHelper + { + /********* + ** Accessors + *********/ + /// <summary>The JSON settings to use when serialising and deserialising files.</summary> + private readonly JsonSerializerSettings JsonSettings = new JsonSerializerSettings + { + Formatting = Formatting.Indented, + ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded + Converters = new List<JsonConverter> + { + new SelectiveStringEnumConverter(typeof(Buttons), typeof(Keys), typeof(SButton)) + } + }; + + + /********* + ** Public methods + *********/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> + public TModel ReadJsonFile<TModel>(string fullPath) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // read file + string json; + try + { + json = File.ReadAllText(fullPath); + } + catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + { + return null; + } + + // deserialise model + try + { + return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); + } + catch (JsonReaderException ex) + { + string message = $"The file at {fullPath} doesn't seem to be valid JSON."; + + string text = File.ReadAllText(fullPath); + if (text.Contains("“") || text.Contains("”")) + message += " Found curly quotes in the text; note that only straight quotes are allowed in JSON."; + + message += $"\nTechnical details: {ex.Message}"; + throw new JsonReaderException(message); + } + } + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="fullPath">The absolete file path.</param> + /// <param name="model">The model to save.</param> + /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> + public void WriteJsonFile<TModel>(string fullPath, TModel model) + where TModel : class + { + // validate + if (string.IsNullOrWhiteSpace(fullPath)) + throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); + + // create directory if needed + string dir = Path.GetDirectoryName(fullPath); + if (dir == null) + throw new ArgumentException("The file path is invalid.", nameof(fullPath)); + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + // write file + string json = JsonConvert.SerializeObject(model, this.JsonSettings); + File.WriteAllText(fullPath, json); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs new file mode 100644 index 00000000..11ffdccb --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/SFieldConverter.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>Overrides how SMAPI reads and writes <see cref="ISemanticVersion"/> and <see cref="IManifestDependency"/> fields.</summary> + internal class SFieldConverter : JsonConverter + { + /********* + ** Accessors + *********/ + /// <summary>Whether this converter can write JSON.</summary> + public override bool CanWrite => false; + + + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="objectType">The object type.</param> + public override bool CanConvert(Type objectType) + { + return + objectType == typeof(ISemanticVersion) + || objectType == typeof(IManifestDependency[]) + || objectType == typeof(ModCompatibilityID[]); + } + + /// <summary>Reads the JSON representation of the object.</summary> + /// <param name="reader">The JSON reader.</param> + /// <param name="objectType">The object type.</param> + /// <param name="existingValue">The object being read.</param> + /// <param name="serializer">The calling serializer.</param> + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + { + // semantic version + if (objectType == typeof(ISemanticVersion)) + { + JToken token = JToken.Load(reader); + switch (token.Type) + { + case JTokenType.Object: + { + JObject obj = (JObject)token; + int major = obj.Value<int>(nameof(ISemanticVersion.MajorVersion)); + int minor = obj.Value<int>(nameof(ISemanticVersion.MinorVersion)); + int patch = obj.Value<int>(nameof(ISemanticVersion.PatchVersion)); + string build = obj.Value<string>(nameof(ISemanticVersion.Build)); + return new SemanticVersion(major, minor, patch, build); + } + + case JTokenType.String: + { + string str = token.Value<string>(); + if (string.IsNullOrWhiteSpace(str)) + return null; + if (!SemanticVersion.TryParse(str, out ISemanticVersion version)) + throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta."); + return version; + } + + default: + throw new SParseException($"Can't parse semantic version from {token.Type}, must be an object or string."); + } + } + + // manifest dependency + if (objectType == typeof(IManifestDependency[])) + { + List<IManifestDependency> result = new List<IManifestDependency>(); + foreach (JObject obj in JArray.Load(reader).Children<JObject>()) + { + string uniqueID = obj.Value<string>(nameof(IManifestDependency.UniqueID)); + string minVersion = obj.Value<string>(nameof(IManifestDependency.MinimumVersion)); +#if SMAPI_1_x + result.Add(new ManifestDependency(uniqueID, minVersion)); +#else + bool required = obj.Value<bool?>(nameof(IManifestDependency.IsRequired)) ?? true; + result.Add(new ManifestDependency(uniqueID, minVersion, required)); +#endif + } + return result.ToArray(); + } + + // mod compatibility ID + if (objectType == typeof(ModCompatibilityID[])) + { + List<ModCompatibilityID> result = new List<ModCompatibilityID>(); + foreach (JToken child in JArray.Load(reader).Children()) + { + result.Add(child is JValue value + ? new ModCompatibilityID(value.Value<string>()) + : child.ToObject<ModCompatibilityID>() + ); + } + return result.ToArray(); + } + + // unknown + throw new NotSupportedException($"Unknown type '{objectType?.FullName}'."); + } + + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + } +} diff --git a/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs b/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs new file mode 100644 index 00000000..37108556 --- /dev/null +++ b/src/StardewModdingAPI/Framework/Serialisation/SelectiveStringEnumConverter.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Converters; + +namespace StardewModdingAPI.Framework.Serialisation +{ + /// <summary>A variant of <see cref="StringEnumConverter"/> which only converts certain enums.</summary> + internal class SelectiveStringEnumConverter : StringEnumConverter + { + /********* + ** Properties + *********/ + /// <summary>The enum type names to convert.</summary> + private readonly HashSet<string> Types; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="types">The enum types to convert.</param> + public SelectiveStringEnumConverter(params Type[] types) + { + this.Types = new HashSet<string>(types.Select(p => p.FullName)); + } + + /// <summary>Get whether this instance can convert the specified object type.</summary> + /// <param name="type">The object type.</param> + public override bool CanConvert(Type type) + { + return + base.CanConvert(type) + && this.Types.Contains((Nullable.GetUnderlyingType(type) ?? type).FullName); + } + } +} diff --git a/src/StardewModdingAPI/Framework/UpdateHelper.cs b/src/StardewModdingAPI/Framework/UpdateHelper.cs new file mode 100644 index 00000000..e01e55c8 --- /dev/null +++ b/src/StardewModdingAPI/Framework/UpdateHelper.cs @@ -0,0 +1,37 @@ +using System.IO; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using Newtonsoft.Json; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides utility methods for mod updates.</summary> + internal class UpdateHelper + { + /********* + ** Public methods + *********/ + /// <summary>Get the latest release from a GitHub repository.</summary> + /// <param name="repository">The name of the repository from which to fetch releases (like "cjsu/SMAPI").</param> + public static async Task<GitRelease> GetLatestVersionAsync(string repository) + { + // build request + // (avoid HttpClient for Mac compatibility) + HttpWebRequest request = WebRequest.CreateHttp($"https://api.github.com/repos/{repository}/releases/latest"); + AssemblyName assembly = typeof(UpdateHelper).Assembly.GetName(); + request.UserAgent = $"{assembly.Name}/{assembly.Version}"; + request.Accept = "application/vnd.github.v3+json"; + + // fetch data + using (WebResponse response = await request.GetResponseAsync()) + using (Stream responseStream = response.GetResponseStream()) + using (StreamReader reader = new StreamReader(responseStream)) + { + string responseText = reader.ReadToEnd(); + return JsonConvert.DeserializeObject<GitRelease>(responseText); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs new file mode 100644 index 00000000..0d8487bb --- /dev/null +++ b/src/StardewModdingAPI/Framework/Utilities/ContextHash.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>A <see cref="HashSet{T}"/> wrapper meant for tracking recursive contexts.</summary> + /// <typeparam name="T">The key type.</typeparam> + internal class ContextHash<T> : HashSet<T> + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ContextHash() { } + + /// <summary>Construct an instance.</summary> + /// <param name="comparer">The <see cref="IEqualityComparer{T}"/> implementation to use when comparing values in the set, or <c>null</c> to use the default comparer for the set type.</param> + public ContextHash(IEqualityComparer<T> comparer) + : base(comparer) { } + + /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary> + /// <param name="key">The key to add.</param> + /// <param name="action">The action to perform.</param> + /// <exception cref="InvalidOperationException">The specified key is already added.</exception> + public void Track(T key, Action action) + { + if(this.Contains(key)) + throw new InvalidOperationException($"Can't track context for key {key} because it's already added."); + + this.Add(key); + try + { + action(); + } + finally + { + this.Remove(key); + } + } + + /// <summary>Add a key while an action is in progress, and remove it when it completes.</summary> + /// <typeparam name="TResult">The value type returned by the method.</typeparam> + /// <param name="key">The key to add.</param> + /// <param name="action">The action to perform.</param> + public TResult Track<TResult>(T key, Func<TResult> action) + { + if (this.Contains(key)) + throw new InvalidOperationException($"Can't track context for key {key} because it's already added."); + + this.Add(key); + try + { + return action(); + } + finally + { + this.Remove(key); + } + } + } +} diff --git a/src/StardewModdingAPI/Framework/Utilities/Countdown.cs b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs new file mode 100644 index 00000000..921a35ce --- /dev/null +++ b/src/StardewModdingAPI/Framework/Utilities/Countdown.cs @@ -0,0 +1,44 @@ +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>Counts down from a baseline value.</summary> + internal class Countdown + { + /********* + ** Accessors + *********/ + /// <summary>The initial value from which to count down.</summary> + public int Initial { get; } + + /// <summary>The current value.</summary> + public int Current { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="initial">The initial value from which to count down.</param> + public Countdown(int initial) + { + this.Initial = initial; + this.Current = initial; + } + + /// <summary>Reduce the current value by one.</summary> + /// <returns>Returns whether the value was decremented (i.e. wasn't already zero).</returns> + public bool Decrement() + { + if (this.Current <= 0) + return false; + + this.Current--; + return true; + } + + /// <summary>Restart the countdown.</summary> + public void Reset() + { + this.Current = this.Initial; + } + } +} diff --git a/src/StardewModdingAPI/IAssetData.cs b/src/StardewModdingAPI/IAssetData.cs new file mode 100644 index 00000000..c3021144 --- /dev/null +++ b/src/StardewModdingAPI/IAssetData.cs @@ -0,0 +1,47 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Generic metadata and methods for a content asset being loaded.</summary> + /// <typeparam name="TValue">The expected data type.</typeparam> + public interface IAssetData<TValue> : IAssetInfo + { + /********* + ** Accessors + *********/ + /// <summary>The content data being read.</summary> + TValue Data { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Replace the entire content value with the given value. This is generally not recommended, since it may break compatibility with other mods or different versions of the game.</summary> + /// <param name="value">The new content value.</param> + /// <exception cref="ArgumentNullException">The <paramref name="value"/> is null.</exception> + /// <exception cref="InvalidCastException">The <paramref name="value"/>'s type is not compatible with the loaded asset's type.</exception> + void ReplaceWith(TValue value); + } + + /// <summary>Generic metadata and methods for a content asset being loaded.</summary> + public interface IAssetData : IAssetData<object> + { + /********* + ** Public methods + *********/ + /// <summary>Get a helper to manipulate the data as a dictionary.</summary> + /// <typeparam name="TKey">The expected dictionary key.</typeparam> + /// <typeparam name="TValue">The expected dictionary value.</typeparam> + /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> + IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>(); + + /// <summary>Get a helper to manipulate the data as an image.</summary> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + IAssetDataForImage AsImage(); + + /// <summary>Get the data as a given type.</summary> + /// <typeparam name="TData">The expected data type.</typeparam> + /// <exception cref="InvalidCastException">The data can't be converted to <typeparamref name="TData"/>.</exception> + TData GetData<TData>(); + } +} diff --git a/src/StardewModdingAPI/IAssetDataForDictionary.cs b/src/StardewModdingAPI/IAssetDataForDictionary.cs new file mode 100644 index 00000000..53c24346 --- /dev/null +++ b/src/StardewModdingAPI/IAssetDataForDictionary.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + public interface IAssetDataForDictionary<TKey, TValue> : IAssetData<IDictionary<TKey, TValue>> + { + /********* + ** Public methods + *********/ + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">The entry value.</param> + void Set(TKey key, TValue value); + + /// <summary>Add or replace an entry in the dictionary.</summary> + /// <param name="key">The entry key.</param> + /// <param name="value">A callback which accepts the current value and returns the new value.</param> + void Set(TKey key, Func<TValue, TValue> value); + + /// <summary>Dynamically replace values in the dictionary.</summary> + /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> + void Set(Func<TKey, TValue, TValue> replacer); + } +} diff --git a/src/StardewModdingAPI/IAssetDataForImage.cs b/src/StardewModdingAPI/IAssetDataForImage.cs new file mode 100644 index 00000000..4584a20e --- /dev/null +++ b/src/StardewModdingAPI/IAssetDataForImage.cs @@ -0,0 +1,23 @@ +using System; +using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Graphics; + +namespace StardewModdingAPI +{ + /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> + public interface IAssetDataForImage : IAssetData<Texture2D> + { + /********* + ** Public methods + *********/ + /// <summary>Overwrite part of the image.</summary> + /// <param name="source">The image to patch into the content.</param> + /// <param name="sourceArea">The part of the <paramref name="source"/> to copy (or <c>null</c> to take the whole texture). This must be within the bounds of the <paramref name="source"/> texture.</param> + /// <param name="targetArea">The part of the content to patch (or <c>null</c> to patch the whole texture). The original content within this area will be erased. This must be within the bounds of the existing spritesheet.</param> + /// <param name="patchMode">Indicates how an image should be patched.</param> + /// <exception cref="ArgumentNullException">One of the arguments is null.</exception> + /// <exception cref="ArgumentOutOfRangeException">The <paramref name="targetArea"/> is outside the bounds of the spritesheet.</exception> + /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> + void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); + } +} diff --git a/src/StardewModdingAPI/IAssetEditor.cs b/src/StardewModdingAPI/IAssetEditor.cs new file mode 100644 index 00000000..d2c6f295 --- /dev/null +++ b/src/StardewModdingAPI/IAssetEditor.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI +{ + /// <summary>Edits matching content assets.</summary> + public interface IAssetEditor + { + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can edit the given asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + bool CanEdit<T>(IAssetInfo asset); + + /// <summary>Edit a matched asset.</summary> + /// <param name="asset">A helper which encapsulates metadata about an asset and enables changes to it.</param> + void Edit<T>(IAssetData asset); + } +} diff --git a/src/StardewModdingAPI/IAssetInfo.cs b/src/StardewModdingAPI/IAssetInfo.cs new file mode 100644 index 00000000..5dd58e2e --- /dev/null +++ b/src/StardewModdingAPI/IAssetInfo.cs @@ -0,0 +1,28 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Basic metadata for a content asset.</summary> + public interface IAssetInfo + { + /********* + ** Accessors + *********/ + /// <summary>The content's locale code, if the content is localised.</summary> + string Locale { get; } + + /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + string AssetName { get; } + + /// <summary>The content data type.</summary> + Type DataType { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + bool AssetNameEquals(string path); + } +} diff --git a/src/StardewModdingAPI/IAssetLoader.cs b/src/StardewModdingAPI/IAssetLoader.cs new file mode 100644 index 00000000..ad97b941 --- /dev/null +++ b/src/StardewModdingAPI/IAssetLoader.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI +{ + /// <summary>Provides the initial version for matching assets loaded by the game. SMAPI will raise an error if two mods try to load the same asset; in most cases you should use <see cref="IAssetEditor"/> instead.</summary> + public interface IAssetLoader + { + /********* + ** Public methods + *********/ + /// <summary>Get whether this instance can load the initial version of the given asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + bool CanLoad<T>(IAssetInfo asset); + + /// <summary>Load a matched asset.</summary> + /// <param name="asset">Basic metadata about the asset being loaded.</param> + T Load<T>(IAssetInfo asset); + } +} diff --git a/src/StardewModdingAPI/ICommandHelper.cs b/src/StardewModdingAPI/ICommandHelper.cs new file mode 100644 index 00000000..fb562e32 --- /dev/null +++ b/src/StardewModdingAPI/ICommandHelper.cs @@ -0,0 +1,26 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for managing console commands.</summary> + public interface ICommandHelper : IModLinked + { + /********* + ** Public methods + *********/ + /// <summary>Add a console command.</summary> + /// <param name="name">The command name, which the user must type to trigger it.</param> + /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> + /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> + /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> + /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> + /// <exception cref="ArgumentException">There's already a command with that name.</exception> + ICommandHelper Add(string name, string documentation, Action<string, string[]> callback); + + /// <summary>Trigger a command.</summary> + /// <param name="name">The command name.</param> + /// <param name="arguments">The command arguments.</param> + /// <returns>Returns whether a matching command was triggered.</returns> + bool Trigger(string name, string[] arguments); + } +} diff --git a/src/StardewModdingAPI/IContentHelper.cs b/src/StardewModdingAPI/IContentHelper.cs new file mode 100644 index 00000000..b4557134 --- /dev/null +++ b/src/StardewModdingAPI/IContentHelper.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for loading content assets.</summary> + public interface IContentHelper : IModLinked + { + /********* + ** Accessors + *********/ +#if !SMAPI_1_x + /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + IList<IAssetLoader> AssetLoaders { get; } + + /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + IList<IAssetEditor> AssetEditors { get; } +#endif + + /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + string CurrentLocale { get; } + + /// <summary>The game's current locale as an enum value.</summary> + LocalizedContentManager.LanguageCode CurrentLocaleConstant { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Texture2D"/> and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T Load<T>(string key, ContentSource source = ContentSource.ModFolder); + + /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> + /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> + /// <param name="source">Where to search for a matching content asset.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + string GetActualAssetKey(string key, ContentSource source = ContentSource.ModFolder); + +#if !SMAPI_1_x + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="key">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + bool InvalidateCache(string key); + + /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> + /// <typeparam name="T">The asset type to remove from the cache.</typeparam> + /// <returns>Returns whether any assets were invalidated.</returns> + bool InvalidateCache<T>(); +#endif + } +} diff --git a/src/StardewModdingAPI/ICursorPosition.cs b/src/StardewModdingAPI/ICursorPosition.cs new file mode 100644 index 00000000..8fbc115f --- /dev/null +++ b/src/StardewModdingAPI/ICursorPosition.cs @@ -0,0 +1,19 @@ +#if !SMAPI_1_x +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI +{ + /// <summary>Represents a cursor position in the different coordinate systems.</summary> + public interface ICursorPosition + { + /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + Vector2 ScreenPixels { get; } + + /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> + Vector2 Tile { get; } + + /// <summary>The tile position that the game considers under the cursor for purposes of clicking actions. This may be different than <see cref="Tile"/> if that's too far from the player.</summary> + Vector2 GrabTile { get; } + } +} +#endif diff --git a/src/StardewModdingAPI/IManifest.cs b/src/StardewModdingAPI/IManifest.cs new file mode 100644 index 00000000..407db1ce --- /dev/null +++ b/src/StardewModdingAPI/IManifest.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>A manifest which describes a mod for SMAPI.</summary> + public interface IManifest + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + string Name { get; } + + /// <summary>A brief description of the mod.</summary> + string Description { get; } + + /// <summary>The mod author's name.</summary> + string Author { get; } + + /// <summary>The mod version.</summary> + ISemanticVersion Version { get; } + + /// <summary>The minimum SMAPI version required by this mod, if any.</summary> + ISemanticVersion MinimumApiVersion { get; } + + /// <summary>The unique mod ID.</summary> + string UniqueID { get; } + + /// <summary>The name of the DLL in the directory that has the <see cref="Mod.Entry"/> method.</summary> + string EntryDll { get; } + + /// <summary>The other mods that must be loaded before this mod.</summary> + IManifestDependency[] Dependencies { get; } + + /// <summary>Any manifest fields which didn't match a valid field.</summary> + IDictionary<string, object> ExtraFields { get; } + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IManifestDependency.cs b/src/StardewModdingAPI/IManifestDependency.cs new file mode 100644 index 00000000..1fa6c812 --- /dev/null +++ b/src/StardewModdingAPI/IManifestDependency.cs @@ -0,0 +1,20 @@ +namespace StardewModdingAPI +{ + /// <summary>A mod dependency listed in a mod manifest.</summary> + public interface IManifestDependency + { + /********* + ** Accessors + *********/ + /// <summary>The unique mod ID to require.</summary> + string UniqueID { get; } + + /// <summary>The minimum required version (if any).</summary> + ISemanticVersion MinimumVersion { get; } + +#if !SMAPI_1_x + /// <summary>Whether the dependency must be installed to use the mod.</summary> + bool IsRequired { get; } +#endif + } +} diff --git a/src/StardewModdingAPI/IMod.cs b/src/StardewModdingAPI/IMod.cs new file mode 100644 index 00000000..35ac7c0f --- /dev/null +++ b/src/StardewModdingAPI/IMod.cs @@ -0,0 +1,26 @@ +namespace StardewModdingAPI +{ + /// <summary>The implementation for a Stardew Valley mod.</summary> + public interface IMod + { + /********* + ** Accessors + *********/ + /// <summary>Provides simplified APIs for writing mods.</summary> + IModHelper Helper { get; } + + /// <summary>Writes messages to the console and log file.</summary> + IMonitor Monitor { get; } + + /// <summary>The mod's manifest.</summary> + IManifest ModManifest { get; } + + + /********* + ** Public methods + *********/ + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + void Entry(IModHelper helper); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IModHelper.cs b/src/StardewModdingAPI/IModHelper.cs new file mode 100644 index 00000000..116e8508 --- /dev/null +++ b/src/StardewModdingAPI/IModHelper.cs @@ -0,0 +1,58 @@ +namespace StardewModdingAPI +{ + /// <summary>Provides simplified APIs for writing mods.</summary> + public interface IModHelper + { + /********* + ** Accessors + *********/ + /// <summary>The full path to the mod's folder.</summary> + string DirectoryPath { get; } + + /// <summary>An API for loading content assets.</summary> + IContentHelper Content { get; } + + /// <summary>Simplifies access to private game code.</summary> + IReflectionHelper Reflection { get; } + + /// <summary>Metadata about loaded mods.</summary> + IModRegistry ModRegistry { get; } + + /// <summary>An API for managing console commands.</summary> + ICommandHelper ConsoleCommands { get; } + + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + ITranslationHelper Translation { get; } + + + /********* + ** Public methods + *********/ + /**** + ** Mod config file + ****/ + /// <summary>Read the mod's configuration file (and create it if needed).</summary> + /// <typeparam name="TConfig">The config class type. This should be a plain class that has public properties for the settings you want. These can be complex types.</typeparam> + TConfig ReadConfig<TConfig>() where TConfig : class, new(); + + /// <summary>Save to the mod's configuration file.</summary> + /// <typeparam name="TConfig">The config class type.</typeparam> + /// <param name="config">The config settings to save.</param> + void WriteConfig<TConfig>(TConfig config) where TConfig : class, new(); + + /**** + ** Generic JSON files + ****/ + /// <summary>Read a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + TModel ReadJsonFile<TModel>(string path) where TModel : class; + + /// <summary>Save to a JSON file.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="path">The file path relative to the mod directory.</param> + /// <param name="model">The model to save.</param> + void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IModLinked.cs b/src/StardewModdingAPI/IModLinked.cs new file mode 100644 index 00000000..172ee30c --- /dev/null +++ b/src/StardewModdingAPI/IModLinked.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>An instance linked to a mod.</summary> + public interface IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the mod for which the instance was created.</summary> + string ModID { get; } + } +} diff --git a/src/StardewModdingAPI/IModRegistry.cs b/src/StardewModdingAPI/IModRegistry.cs new file mode 100644 index 00000000..5ef3fd65 --- /dev/null +++ b/src/StardewModdingAPI/IModRegistry.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for fetching metadata about loaded mods.</summary> + public interface IModRegistry : IModLinked + { + /// <summary>Get metadata for all loaded mods.</summary> + IEnumerable<IManifest> GetAll(); + + /// <summary>Get metadata for a loaded mod.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> + IManifest Get(string uniqueID); + + /// <summary>Get whether a mod has been loaded.</summary> + /// <param name="uniqueID">The mod's unique ID.</param> + bool IsLoaded(string uniqueID); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IMonitor.cs b/src/StardewModdingAPI/IMonitor.cs new file mode 100644 index 00000000..62c479bc --- /dev/null +++ b/src/StardewModdingAPI/IMonitor.cs @@ -0,0 +1,25 @@ +namespace StardewModdingAPI +{ + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + public interface IMonitor + { + /********* + ** Accessors + *********/ + /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> + bool IsExiting { get; } + + + /********* + ** Methods + *********/ + /// <summary>Log a message for the player or developer.</summary> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + void Log(string message, LogLevel level = LogLevel.Debug); + + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="reason">The reason for the shutdown.</param> + void ExitGameImmediately(string reason); + } +} diff --git a/src/StardewModdingAPI/IPrivateField.cs b/src/StardewModdingAPI/IPrivateField.cs new file mode 100644 index 00000000..3e681c12 --- /dev/null +++ b/src/StardewModdingAPI/IPrivateField.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private field obtained through reflection.</summary> + /// <typeparam name="TValue">The field value type.</typeparam> + public interface IPrivateField<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + FieldInfo FieldInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get the field value.</summary> + TValue GetValue(); + + /// <summary>Set the field value.</summary> + //// <param name="value">The value to set.</param> + void SetValue(TValue value); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IPrivateMethod.cs b/src/StardewModdingAPI/IPrivateMethod.cs new file mode 100644 index 00000000..67fc8b3c --- /dev/null +++ b/src/StardewModdingAPI/IPrivateMethod.cs @@ -0,0 +1,27 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private method obtained through reflection.</summary> + public interface IPrivateMethod + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + MethodInfo MethodInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Invoke the method.</summary> + /// <typeparam name="TValue">The return type.</typeparam> + /// <param name="arguments">The method arguments to pass in.</param> + TValue Invoke<TValue>(params object[] arguments); + + /// <summary>Invoke the method.</summary> + /// <param name="arguments">The method arguments to pass in.</param> + void Invoke(params object[] arguments); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IPrivateProperty.cs b/src/StardewModdingAPI/IPrivateProperty.cs new file mode 100644 index 00000000..8d67fa7a --- /dev/null +++ b/src/StardewModdingAPI/IPrivateProperty.cs @@ -0,0 +1,26 @@ +using System.Reflection; + +namespace StardewModdingAPI +{ + /// <summary>A private property obtained through reflection.</summary> + /// <typeparam name="TValue">The property value type.</typeparam> + public interface IPrivateProperty<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>The reflection metadata.</summary> + PropertyInfo PropertyInfo { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get the property value.</summary> + TValue GetValue(); + + /// <summary>Set the property value.</summary> + //// <param name="value">The value to set.</param> + void SetValue(TValue value); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/IReflectionHelper.cs b/src/StardewModdingAPI/IReflectionHelper.cs new file mode 100644 index 00000000..fb2c7861 --- /dev/null +++ b/src/StardewModdingAPI/IReflectionHelper.cs @@ -0,0 +1,67 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for accessing private game code.</summary> + public interface IReflectionHelper : IModLinked + { + /********* + ** Public methods + *********/ + /// <summary>Get a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateField<TValue> GetPrivateField<TValue>(object obj, string name, bool required = true); + + /// <summary>Get a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateField<TValue> GetPrivateField<TValue>(Type type, string name, bool required = true); + + /// <summary>Get a private instance property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="obj">The object which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + IPrivateProperty<TValue> GetPrivateProperty<TValue>(object obj, string name, bool required = true); + + /// <summary>Get a private static property.</summary> + /// <typeparam name="TValue">The property type.</typeparam> + /// <param name="type">The type which has the property.</param> + /// <param name="name">The property name.</param> + /// <param name="required">Whether to throw an exception if the private property is not found.</param> + IPrivateProperty<TValue> GetPrivateProperty<TValue>(Type type, string name, bool required = true); + + /// <summary>Get the value of a private instance field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="obj">The object which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(object,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks> + TValue GetPrivateValue<TValue>(object obj, string name, bool required = true); + + /// <summary>Get the value of a private static field.</summary> + /// <typeparam name="TValue">The field type.</typeparam> + /// <param name="type">The type which has the field.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + /// <remarks>This is a shortcut for <see cref="GetPrivateField{TValue}(Type,string,bool)"/> followed by <see cref="IPrivateField{TValue}.GetValue"/>.</remarks> + TValue GetPrivateValue<TValue>(Type type, string name, bool required = true); + + /// <summary>Get a private instance method.</summary> + /// <param name="obj">The object which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateMethod GetPrivateMethod(object obj, string name, bool required = true); + + /// <summary>Get a private static method.</summary> + /// <param name="type">The type which has the method.</param> + /// <param name="name">The field name.</param> + /// <param name="required">Whether to throw an exception if the private field is not found.</param> + IPrivateMethod GetPrivateMethod(Type type, string name, bool required = true); + } +} diff --git a/src/StardewModdingAPI/ISemanticVersion.cs b/src/StardewModdingAPI/ISemanticVersion.cs new file mode 100644 index 00000000..c1a4ca3a --- /dev/null +++ b/src/StardewModdingAPI/ISemanticVersion.cs @@ -0,0 +1,62 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>A semantic version with an optional release tag.</summary> + public interface ISemanticVersion : IComparable<ISemanticVersion> +#if !SMAPI_1_x + , IEquatable<ISemanticVersion> +#endif + { + /********* + ** Accessors + *********/ + /// <summary>The major version incremented for major API changes.</summary> + int MajorVersion { get; } + + /// <summary>The minor version incremented for backwards-compatible changes.</summary> + int MinorVersion { get; } + + /// <summary>The patch version for backwards-compatible bug fixes.</summary> + int PatchVersion { get; } + + /// <summary>An optional build tag.</summary> + string Build { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + bool IsOlderThan(ISemanticVersion other); + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + bool IsOlderThan(string other); + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + bool IsNewerThan(ISemanticVersion other); + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + bool IsNewerThan(string other); + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + bool IsBetween(ISemanticVersion min, ISemanticVersion max); + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + bool IsBetween(string min, string max); + + /// <summary>Get a string representation of the version.</summary> + string ToString(); + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/ITranslationHelper.cs b/src/StardewModdingAPI/ITranslationHelper.cs new file mode 100644 index 00000000..c4b72444 --- /dev/null +++ b/src/StardewModdingAPI/ITranslationHelper.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public interface ITranslationHelper : IModLinked + { + /********* + ** 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> + IEnumerable<Translation> 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/Log.cs b/src/StardewModdingAPI/Log.cs new file mode 100644 index 00000000..60220ad8 --- /dev/null +++ b/src/StardewModdingAPI/Log.cs @@ -0,0 +1,320 @@ +#if SMAPI_1_x +using System; +using System.Threading; +using StardewModdingAPI.Framework; +using Monitor = StardewModdingAPI.Framework.Monitor; + +namespace StardewModdingAPI +{ + /// <summary>A singleton which logs messages to the SMAPI console and log file.</summary> + [Obsolete("Use " + nameof(Mod) + "." + nameof(Mod.Monitor))] + public static class Log + { + /********* + ** Properties + *********/ + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + /// <summary>The underlying logger.</summary> + private static Monitor Monitor; + + /// <summary>Tracks the installed mods.</summary> + private static ModRegistry ModRegistry; + + + /********* + ** Public methods + *********/ + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + /// <param name="monitor">The underlying logger.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + internal static void Shim(DeprecationManager deprecationManager, Monitor monitor, ModRegistry modRegistry) + { + Log.DeprecationManager = deprecationManager; + Log.Monitor = monitor; + Log.ModRegistry = modRegistry; + } + + /**** + ** Exceptions + ****/ + /// <summary>Log an exception event.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + public static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + Log.WarnDeprecated(); + Log.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + } + + /// <summary>Log a thread exception event.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + public static void Application_ThreadException(object sender, ThreadExceptionEventArgs e) + { + Log.WarnDeprecated(); + Log.Monitor.Log($"Critical thread exception: {e.Exception}", LogLevel.Error); + } + + /**** + ** Synchronous logging + ****/ + /// <summary>Synchronously log a message to the console. NOTE: synchronous logging is discouraged; use asynchronous methods instead.</summary> + /// <param name="message">The message to log.</param> + /// <param name="color">The message color.</param> + public static void SyncColour(object message, ConsoleColor color) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), color); + } + + /**** + ** Asynchronous logging + ****/ + /// <summary>Asynchronously log a message to the console with the specified color.</summary> + /// <param name="message">The message to log.</param> + /// <param name="color">The message color.</param> + public static void AsyncColour(object message, ConsoleColor color) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), color); + } + + /// <summary>Asynchronously log a message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Async(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Gray); + } + + /// <summary>Asynchronously log a red message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncR(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Red); + } + + /// <summary>Asynchronously log an orange message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncO(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.DarkYellow); + } + + /// <summary>Asynchronously log a yellow message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncY(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Yellow); + } + + /// <summary>Asynchronously log a green message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncG(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Green); + } + + /// <summary>Asynchronously log a cyan message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncC(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Cyan); + } + + /// <summary>Asynchronously log a magenta message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void AsyncM(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Magenta); + } + + /// <summary>Asynchronously log a warning to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Warning(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Yellow, LogLevel.Warn); + } + + /// <summary>Asynchronously log an error to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Error(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Red, LogLevel.Error); + } + + /// <summary>Asynchronously log a success message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Success(object message) + { + Log.WarnDeprecated(); + Log.AsyncG(message); + } + + /// <summary>Asynchronously log an info message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Info(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.White, LogLevel.Info); + } + + /// <summary>Asynchronously log an info message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Out(object message) + { + Log.WarnDeprecated(); + Log.Async($"[OUT] {message}"); + } + + /// <summary>Asynchronously log a debug message to the console.</summary> + /// <param name="message">The message to log.</param> + public static void Debug(object message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.DarkGray); + } + + /// <summary>Asynchronously log a message to the file that's not shown in the console.</summary> + /// <param name="message">The message to log.</param> + internal static void LogToFile(string message) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message, ConsoleColor.DarkGray, LogLevel.Trace); + } + + /// <summary>Obsolete.</summary> + public static void LogValueNotSpecified() + { + Log.WarnDeprecated(); + Log.AsyncR("<value> must be specified"); + } + + /// <summary>Obsolete.</summary> + public static void LogObjectValueNotSpecified() + { + Log.WarnDeprecated(); + Log.AsyncR("<object> and <value> must be specified"); + } + + /// <summary>Obsolete.</summary> + public static void LogValueInvalid() + { + Log.WarnDeprecated(); + Log.AsyncR("<value> is invalid"); + } + + /// <summary>Obsolete.</summary> + public static void LogObjectInvalid() + { + Log.WarnDeprecated(); + Log.AsyncR("<object> is invalid"); + } + + /// <summary>Obsolete.</summary> + public static void LogValueNotInt32() + { + Log.WarnDeprecated(); + Log.AsyncR("<value> must be a whole number (Int32)"); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="disableLogging">Obsolete.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + private static void PrintLog(object message, bool disableLogging, params object[] values) + { + Log.WarnDeprecated(); + Log.Monitor.LegacyLog(Log.GetModName(), message?.ToString(), ConsoleColor.Gray); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Success(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.Success(message); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Verbose(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.Out(message); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Comment(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.AsyncC(message); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Info(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.Info(message); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Error(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.Error(message); + } + + /// <summary>Obsolete.</summary> + /// <param name="message">The message to log.</param> + /// <param name="values">Obsolete.</param> + [Obsolete("Parameter 'values' is no longer supported. Format before logging.")] + public static void Debug(object message, params object[] values) + { + Log.WarnDeprecated(); + Log.Debug(message); + } + + + /********* + ** Private methods + *********/ + /// <summary>Raise a deprecation warning.</summary> + private static void WarnDeprecated() + { + Log.DeprecationManager.Warn($"the {nameof(Log)} class", "1.1", DeprecationLevel.PendingRemoval); + } + + /// <summary>Get the name of the mod logging a message from the stack.</summary> + private static string GetModName() + { + return Log.ModRegistry.GetModFromStack() ?? "<unknown mod>"; + } + } +} +#endif
\ No newline at end of file diff --git a/src/StardewModdingAPI/LogLevel.cs b/src/StardewModdingAPI/LogLevel.cs new file mode 100644 index 00000000..89647876 --- /dev/null +++ b/src/StardewModdingAPI/LogLevel.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI +{ + /// <summary>The log severity levels.</summary> + public enum LogLevel + { + /// <summary>Tracing info intended for developers.</summary> + Trace, + + /// <summary>Troubleshooting info that may be relevant to the player.</summary> + Debug, + + /// <summary>Info relevant to the player. This should be used judiciously.</summary> + Info, + + /// <summary>An issue the player should be aware of. This should be used rarely.</summary> + Warn, + + /// <summary>A message indicating something went wrong.</summary> + Error, + + /// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary> + Alert + } +}
\ No newline at end of file diff --git a/src/StardewModdingAPI/Metadata/CoreAssets.cs b/src/StardewModdingAPI/Metadata/CoreAssets.cs new file mode 100644 index 00000000..24f23af7 --- /dev/null +++ b/src/StardewModdingAPI/Metadata/CoreAssets.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Buildings; +using StardewValley.Locations; +using StardewValley.Objects; +using StardewValley.Projectiles; +using StardewValley.TerrainFeatures; + +namespace StardewModdingAPI.Metadata +{ + /// <summary>Provides metadata about core assets in the game.</summary> + internal class CoreAssets + { + /********* + ** Properties + *********/ + /// <summary>Normalises an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalisedPath; + + /// <summary>Setters which update static or singleton texture fields indexed by normalised asset key.</summary> + private readonly IDictionary<string, Action<SContentManager, string>> SingletonSetters; + + + /********* + ** Public methods + *********/ + /// <summary>Initialise the core asset data.</summary> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + public CoreAssets(Func<string, string> getNormalisedPath) + { + this.GetNormalisedPath = getNormalisedPath; + this.SingletonSetters = + new Dictionary<string, Action<SContentManager, string>> + { + // from Game1.loadContent + ["LooseSprites\\daybg"] = (content, key) => Game1.daybg = content.Load<Texture2D>(key), + ["LooseSprites\\nightbg"] = (content, key) => Game1.nightbg = content.Load<Texture2D>(key), + ["Maps\\MenuTiles"] = (content, key) => Game1.menuTexture = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\lantern"] = (content, key) => Game1.lantern = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\windowLight"] = (content, key) => Game1.windowLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\sconceLight"] = (content, key) => Game1.sconceLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\greenLight"] = (content, key) => Game1.cauldronLight = content.Load<Texture2D>(key), + ["LooseSprites\\Lighting\\indoorWindowLight"] = (content, key) => Game1.indoorWindowLight = content.Load<Texture2D>(key), + ["LooseSprites\\shadow"] = (content, key) => Game1.shadowTexture = content.Load<Texture2D>(key), + ["LooseSprites\\Cursors"] = (content, key) => Game1.mouseCursors = content.Load<Texture2D>(key), + ["LooseSprites\\ControllerMaps"] = (content, key) => Game1.controllerMaps = content.Load<Texture2D>(key), + ["TileSheets\\animations"] = (content, key) => Game1.animations = content.Load<Texture2D>(key), + ["Data\\Achievements"] = (content, key) => Game1.achievements = content.Load<Dictionary<int, string>>(key), + ["Data\\NPCGiftTastes"] = (content, key) => Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key), + ["Fonts\\SpriteFont1"] = (content, key) => Game1.dialogueFont = content.Load<SpriteFont>(key), + ["Fonts\\SmallFont"] = (content, key) => Game1.smallFont = content.Load<SpriteFont>(key), + ["Fonts\\tinyFont"] = (content, key) => Game1.tinyFont = content.Load<SpriteFont>(key), + ["Fonts\\tinyFontBorder"] = (content, key) => Game1.tinyFontBorder = content.Load<SpriteFont>(key), + ["Maps\\springobjects"] = (content, key) => Game1.objectSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\crops"] = (content, key) => Game1.cropSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\emotes"] = (content, key) => Game1.emoteSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\debris"] = (content, key) => Game1.debrisSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\Craftables"] = (content, key) => Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key), + ["TileSheets\\rain"] = (content, key) => Game1.rainTexture = content.Load<Texture2D>(key), + ["TileSheets\\BuffsIcons"] = (content, key) => Game1.buffsIcons = content.Load<Texture2D>(key), + ["Data\\ObjectInformation"] = (content, key) => Game1.objectInformation = content.Load<Dictionary<int, string>>(key), + ["Data\\BigCraftablesInformation"] = (content, key) => Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key), + ["Characters\\Farmer\\hairstyles"] = (content, key) => FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\shirts"] = (content, key) => FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\hats"] = (content, key) => FarmerRenderer.hatsTexture = content.Load<Texture2D>(key), + ["Characters\\Farmer\\accessories"] = (content, key) => FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key), + ["TileSheets\\furniture"] = (content, key) => Furniture.furnitureTexture = content.Load<Texture2D>(key), + ["LooseSprites\\font_bold"] = (content, key) => SpriteText.spriteTexture = content.Load<Texture2D>(key), + ["LooseSprites\\font_colored"] = (content, key) => SpriteText.coloredTexture = content.Load<Texture2D>(key), + ["TileSheets\\weapons"] = (content, key) => Tool.weaponsTexture = content.Load<Texture2D>(key), + ["TileSheets\\Projectiles"] = (content, key) => Projectile.projectileSheet = content.Load<Texture2D>(key), + + // from Game1.ResetToolSpriteSheet + ["TileSheets\\tools"] = (content, key) => Game1.ResetToolSpriteSheet(), + + // from Bush + ["TileSheets\\bushes"] = (content, key) => Bush.texture = content.Load<Texture2D>(key), + + // from Critter + ["TileSheets\\critters"] = (content, key) => Critter.critterTexture = content.Load<Texture2D>(key), + + // from Farm + ["Buildings\\houses"] = (content, key) => + { + Farm farm = Game1.getFarm(); + if (farm != null) + farm.houseTextures = content.Load<Texture2D>(key); + }, + + // from Farmer + ["Characters\\Farmer\\farmer_base"] = (content, key) => + { + if (Game1.player != null && Game1.player.isMale) + Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); + }, + ["Characters\\Farmer\\farmer_girl_base"] = (content, key) => + { + if (Game1.player != null && !Game1.player.isMale) + Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); + }, + + // from Flooring + ["TerrainFeatures\\Flooring"] = (content, key) => Flooring.floorsTexture = content.Load<Texture2D>(key), + + // from FruitTree + ["TileSheets\\fruitTrees"] = (content, key) => FruitTree.texture = content.Load<Texture2D>(key), + + // from HoeDirt + ["TerrainFeatures\\hoeDirt"] = (content, key) => HoeDirt.lightTexture = content.Load<Texture2D>(key), + ["TerrainFeatures\\hoeDirtDark"] = (content, key) => HoeDirt.darkTexture = content.Load<Texture2D>(key), + ["TerrainFeatures\\hoeDirtSnow"] = (content, key) => HoeDirt.snowTexture = content.Load<Texture2D>(key), + + // from Wallpaper + ["Maps\\walls_and_floors"] = (content, key) => Wallpaper.wallpaperTexture = content.Load<Texture2D>(key) + } + .ToDictionary(p => getNormalisedPath(p.Key), p => p.Value); + } + + /// <summary>Reload one of the game's core assets (if applicable).</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether an asset was reloaded.</returns> + public bool ReloadForKey(SContentManager content, string key) + { + // static assets + if (this.SingletonSetters.TryGetValue(key, out Action<SContentManager, string> reload)) + { + reload(content, key); + return true; + } + + // building textures + if (key.StartsWith(this.GetNormalisedPath("Buildings\\"))) + { + Building[] buildings = this.GetAllBuildings().Where(p => key == this.GetNormalisedPath($"Buildings\\{p.buildingType}")).ToArray(); + if (buildings.Any()) + { + Texture2D texture = content.Load<Texture2D>(key); + foreach (Building building in buildings) + building.texture = texture; + return true; + } + return false; + } + + return false; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get all player-constructed buildings in the world.</summary> + private IEnumerable<Building> GetAllBuildings() + { + return Game1.locations + .OfType<BuildableGameLocation>() + .SelectMany(p => p.buildings); + } + } +} diff --git a/src/StardewModdingAPI/Metadata/InstructionMetadata.cs b/src/StardewModdingAPI/Metadata/InstructionMetadata.cs new file mode 100644 index 00000000..79fabbd2 --- /dev/null +++ b/src/StardewModdingAPI/Metadata/InstructionMetadata.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.ModLoading.Finders; +using StardewModdingAPI.Framework.ModLoading.Rewriters; +using StardewModdingAPI.Framework.ModLoading.Rewriters.Wrappers; +using StardewValley; + +namespace StardewModdingAPI.Metadata +{ + /// <summary>Provides CIL instruction handlers which rewrite mods for compatibility and throw exceptions for incompatible code.</summary> + internal class InstructionMetadata + { + /********* + ** Public methods + *********/ + /// <summary>Get rewriters which detect or fix incompatible CIL instructions in mod assemblies.</summary> + public IEnumerable<IInstructionHandler> GetHandlers() + { + return new IInstructionHandler[] + { + /**** + ** throw exception for incompatible code + ****/ + // changes in Stardew Valley 1.2 (with no rewriters) + new FieldFinder("StardewValley.Item", "set_Name", InstructionHandleResult.NotCompatible), + + // APIs removed in SMAPI 1.9 + new TypeFinder("StardewModdingAPI.Advanced.ConfigFile", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Advanced.IConfigFile", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Entities.SPlayer", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Extensions", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Inheritance.SGame", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Inheritance.SObject", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.LogWriter", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Manifest", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Version", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawDebug", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "DrawTick", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderHudEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPostRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderHudEventNoCheck", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GraphicsEvents", "OnPreRenderGuiEventNoCheck", InstructionHandleResult.NotCompatible), + + // APIs removed in SMAPI 2.0 +#if !SMAPI_1_x + new TypeFinder("StardewModdingAPI.Command", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Config", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Log", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "Initialize", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "LoadContent", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "GameLoaded", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.GameEvents", "FirstUpdateTick", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.PlayerEvents", "LoadedGame", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.PlayerEvents", "FarmerChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "DayOfMonthChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "YearOfGameChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "SeasonOfYearChanged", InstructionHandleResult.NotCompatible), + new EventFinder("StardewModdingAPI.Events.TimeEvents", "OnNewDay", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsCommand", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsFarmerChanged", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsLoadedGameChanged", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsNewDay", InstructionHandleResult.NotCompatible), + new TypeFinder("StardewModdingAPI.Events.EventArgsStringChanged", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PathOnDisk", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "BaseConfigPath", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigFolder", InstructionHandleResult.NotCompatible), + new PropertyFinder("StardewModdingAPI.Mod", "PerSaveConfigPath", InstructionHandleResult.NotCompatible), +#endif + + /**** + ** detect code which may impact game stability + ****/ + new TypeFinder("Harmony.HarmonyInstance", InstructionHandleResult.DetectedGamePatch), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerialiser), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerialiser), + new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.locationSerializer), InstructionHandleResult.DetectedSaveSerialiser), + + /**** + ** rewrite CIL to fix incompatible code + ****/ + // crossplatform + new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchWrapper), onlyIfPlatformChanged: true), + + // Stardew Valley 1.2 + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.activeClickableMenu)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.currentMinigame)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.gameMode)), + new FieldToPropertyRewriter(typeof(Game1), nameof(Game1.player)), + new FieldReplaceRewriter(typeof(Game1), "borderFont", nameof(Game1.smallFont)), + new FieldReplaceRewriter(typeof(Game1), "smoothFont", nameof(Game1.smallFont)), + + // SMAPI 1.9 + new TypeReferenceRewriter("StardewModdingAPI.Inheritance.ItemStackChange", typeof(ItemStackChange)) + }; + } + } +} diff --git a/src/StardewModdingAPI/Mod.cs b/src/StardewModdingAPI/Mod.cs new file mode 100644 index 00000000..b5607234 --- /dev/null +++ b/src/StardewModdingAPI/Mod.cs @@ -0,0 +1,138 @@ +using System; +using System.IO; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Models; + +namespace StardewModdingAPI +{ + /// <summary>The base class for a mod.</summary> + public class Mod : IMod, IDisposable + { + /********* + ** Properties + *********/ +#if SMAPI_1_x + /// <summary>Manages deprecation warnings.</summary> + private static DeprecationManager DeprecationManager; + + + /// <summary>The backing field for <see cref="Mod.PathOnDisk"/>.</summary> + private string _pathOnDisk; +#endif + + + /********* + ** Accessors + *********/ + /// <summary>Provides simplified APIs for writing mods.</summary> + public IModHelper Helper { get; internal set; } + + /// <summary>Writes messages to the console and log file.</summary> + public IMonitor Monitor { get; internal set; } + + /// <summary>The mod's manifest.</summary> + public IManifest ModManifest { get; internal set; } + +#if SMAPI_1_x + /// <summary>The full path to the mod's directory on the disk.</summary> + [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.DirectoryPath) + " instead")] + public string PathOnDisk + { + get + { + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0", DeprecationLevel.PendingRemoval); + return this._pathOnDisk; + } + internal set { this._pathOnDisk = value; } + } + + /// <summary>The full path to the mod's <c>config.json</c> file on the disk.</summary> + [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadConfig) + " instead")] + public string BaseConfigPath + { + get + { + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.BaseConfigPath)}", "1.0", DeprecationLevel.PendingRemoval); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings + return Path.Combine(this.PathOnDisk, "config.json"); + } + } + + /// <summary>The full path to the per-save configs folder (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadJsonFile) + " instead")] + public string PerSaveConfigFolder => this.GetPerSaveConfigFolder(); + + /// <summary>The full path to the per-save configuration file for the current save (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.ReadJsonFile) + " instead")] + public string PerSaveConfigPath + { + get + { + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigPath)}", "1.0", DeprecationLevel.PendingRemoval); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0"); // avoid redundant warnings + return Context.IsSaveLoaded ? Path.Combine(this.PerSaveConfigFolder, $"{Constants.SaveFolderName}.json") : ""; + } + } +#endif + + + /********* + ** Public methods + *********/ +#if SMAPI_1_x + /// <summary>Injects types required for backwards compatibility.</summary> + /// <param name="deprecationManager">Manages deprecation warnings.</param> + internal static void Shim(DeprecationManager deprecationManager) + { + Mod.DeprecationManager = deprecationManager; + } + + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + [Obsolete("This overload is obsolete since SMAPI 1.0.")] + public virtual void Entry(params object[] objects) { } +#endif + + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + public virtual void Entry(IModHelper helper) { } + + /// <summary>Release or reset unmanaged resources.</summary> + public void Dispose() + { + (this.Helper as IDisposable)?.Dispose(); // deliberate do this outside overridable dispose method so mods don't accidentally suppress it + this.Dispose(true); + GC.SuppressFinalize(this); + } + + + /********* + ** Private methods + *********/ +#if SMAPI_1_x + /// <summary>Get the full path to the per-save configuration file for the current save (if <see cref="Manifest.PerSaveConfigs"/> is <c>true</c>).</summary> + [Obsolete] + private string GetPerSaveConfigFolder() + { + Mod.DeprecationManager.Warn($"{nameof(Mod)}.{nameof(Mod.PerSaveConfigFolder)}", "1.0", DeprecationLevel.PendingRemoval); + Mod.DeprecationManager.MarkWarned($"{nameof(Mod)}.{nameof(Mod.PathOnDisk)}", "1.0"); // avoid redundant warnings + + if (!((Manifest)this.ModManifest).PerSaveConfigs) + { + this.Monitor.Log("Tried to fetch the per-save config folder, but this mod isn't configured to use per-save config files.", LogLevel.Error); + return ""; + } + return Path.Combine(this.PathOnDisk, "psconfigs"); + } +#endif + + /// <summary>Release or reset unmanaged resources when the game exits. There's no guarantee this will be called on every exit.</summary> + /// <param name="disposing">Whether the instance is being disposed explicitly rather than finalised. If this is false, the instance shouldn't dispose other objects since they may already be finalised.</param> + protected virtual void Dispose(bool disposing) { } + + /// <summary>Destruct the instance.</summary> + ~Mod() + { + this.Dispose(false); + } + } +} diff --git a/src/StardewModdingAPI/PatchMode.cs b/src/StardewModdingAPI/PatchMode.cs new file mode 100644 index 00000000..b4286a89 --- /dev/null +++ b/src/StardewModdingAPI/PatchMode.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Indicates how an image should be patched.</summary> + public enum PatchMode + { + /// <summary>Erase the original content within the area before drawing the new content.</summary> + Replace, + + /// <summary>Draw the new content over the original content, so the original content shows through any transparent pixels.</summary> + Overlay + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs new file mode 100644 index 00000000..84af2777 --- /dev/null +++ b/src/StardewModdingAPI/Program.cs @@ -0,0 +1,987 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Threading; +#if SMAPI_FOR_WINDOWS +using System.Management; +using System.Windows.Forms; +#endif +using Newtonsoft.Json; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; +using StardewValley; +using Monitor = StardewModdingAPI.Framework.Monitor; +using SObject = StardewValley.Object; + +namespace StardewModdingAPI +{ + /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> + internal class Program : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The log file to which to write messages.</summary> + private readonly LogFileManager LogFile; + + /// <summary>Manages console output interception.</summary> + private readonly ConsoleInterceptionManager ConsoleManager = new ConsoleInterceptionManager(); + + /// <summary>The core logger and monitor for SMAPI.</summary> + private readonly Monitor Monitor; + + /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> + private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + + /// <summary>Simplifies access to private game code.</summary> + private readonly Reflector Reflection = new Reflector(); + + /// <summary>The underlying game instance.</summary> + private SGame GameInstance; + + /// <summary>The underlying content manager.</summary> + private SContentManager ContentManager => this.GameInstance.SContentManager; + + /// <summary>The SMAPI configuration settings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private SConfig Settings; + + /// <summary>Tracks the installed mods.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private ModRegistry ModRegistry; + + /// <summary>Manages deprecation warnings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private DeprecationManager DeprecationManager; + + /// <summary>Manages console commands.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private CommandManager CommandManager; + + /// <summary>Whether the game is currently running.</summary> + private bool IsGameRunning; + + /// <summary>Whether the program has been disposed.</summary> + private bool IsDisposed; + + + /********* + ** Public methods + *********/ + /// <summary>The main entry point which hooks into and launches the game.</summary> + /// <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"); + + // get log path from arguments + string logPath = null; + { + int pathIndex = Array.LastIndexOf(args, "--log-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + { + logPath = args[pathIndex]; + if (!Path.IsPathRooted(logPath)) + logPath = Path.Combine(Constants.LogDir, logPath); + } + } + if (string.IsNullOrWhiteSpace(logPath)) + logPath = Constants.DefaultLogPath; + + // load SMAPI + using (Program program = new Program(writeToConsole, logPath)) + program.RunInteractively(); + } + + /// <summary>Construct an instance.</summary> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + /// <param name="logPath">The full file path to which to write log messages.</param> + public Program(bool writeToConsole, string logPath) + { + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource) { WriteToConsole = writeToConsole }; + } + + /// <summary>Launch SMAPI.</summary> + [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + public void RunInteractively() + { + // initialise SMAPI + try + { + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {this.GetFriendlyPlatformName()}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {Constants.ModPath}"); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); +#if SMAPI_1_x + this.Monitor.Log("Preparing SMAPI..."); +#endif + + // validate paths + this.VerifyPath(Constants.ModPath); + this.VerifyPath(Constants.LogDir); + + // validate game version + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) + { + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + { + this.Monitor.Log($"Oops! You're running Stardew Valley {Constants.GameVersion}, but this version of SMAPI is only compatible up to Stardew Valley {Constants.MaximumGameVersion}. Please check for a newer version of SMAPI.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // add error handlers +#if SMAPI_FOR_WINDOWS + Application.ThreadException += (sender, e) => this.Monitor.Log($"Critical thread exception: {e.Exception.GetLogSummary()}", LogLevel.Error); + Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); +#endif + AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + + // override game + this.GameInstance = new SGame(this.Monitor, this.Reflection); + StardewValley.Program.gamePtr = this.GameInstance; + + // add exit handler + new Thread(() => + { + this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + if (this.IsGameRunning) + { + try + { + File.WriteAllText(Constants.FatalCrashMarker, string.Empty); + File.Copy(this.LogFile.Path, Constants.FatalCrashLog, overwrite: true); + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed trying to track the crash details: {ex.GetLogSummary()}"); + } + + this.GameInstance.Exit(); + } + }).Start(); + + // hook into game events +#if SMAPI_FOR_WINDOWS + ((Form)Control.FromHandle(this.GameInstance.Window.Handle)).FormClosing += (sender, args) => this.Dispose(); +#endif + this.GameInstance.Exiting += (sender, e) => this.Dispose(); + 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.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + } + catch (Exception ex) + { + this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } + + // show details if game crashed during last session + if (File.Exists(Constants.FatalCrashMarker)) + { + this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: http://community.playstarbound.com/threads/108375/.", LogLevel.Error); + this.Monitor.Log($"If you ask for help, make sure to attach this file: {Constants.FatalCrashLog}", LogLevel.Error); + this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info); + Console.ReadKey(); + File.Delete(Constants.FatalCrashLog); + File.Delete(Constants.FatalCrashMarker); + } + + // start game +#if SMAPI_1_x + this.Monitor.Log("Starting game..."); +#else + this.Monitor.Log("Starting game...", LogLevel.Trace); +#endif + try + { + this.IsGameRunning = true; + this.GameInstance.Run(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); + } + } + +#if SMAPI_1_x + /// <summary>Get a monitor for legacy code which doesn't have one passed in.</summary> + [Obsolete("This method should only be used when needed for backwards compatibility.")] + internal IMonitor GetLegacyMonitorForMod() + { + string modName = this.ModRegistry.GetModFromStack() ?? "unknown"; + return this.GetSecondaryMonitor(modName); + } +#endif + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Monitor.Log("Disposing...", LogLevel.Trace); + + // skip if already disposed + if (this.IsDisposed) + return; + this.IsDisposed = true; + + // dispose mod data + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + { + try + { + (mod.Mod as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + this.Monitor.Log($"The {mod.DisplayName} mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); + } + } + + // dispose core components + this.IsGameRunning = false; + this.LogFile?.Dispose(); + this.ConsoleManager?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + } + + + /********* + ** 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.GameVersion}, but the oldest supported version is {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() + { + // load settings + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.GameInstance.VerboseLogging = this.Settings.VerboseLogging; + + // load core components + this.ModRegistry = new ModRegistry(); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + this.CommandManager = new CommandManager(); + +#if SMAPI_1_x + // inject compatibility shims +#pragma warning disable 618 + Command.Shim(this.CommandManager, this.DeprecationManager, this.ModRegistry); + Config.Shim(this.DeprecationManager); + Log.Shim(this.DeprecationManager, this.GetSecondaryMonitor("legacy mod"), this.ModRegistry); + Mod.Shim(this.DeprecationManager); + GameEvents.Shim(this.DeprecationManager); + PlayerEvents.Shim(this.DeprecationManager); + TimeEvents.Shim(this.DeprecationManager); +#pragma warning restore 618 +#endif + + // redirect direct console output + { + Monitor monitor = this.GetSecondaryMonitor("Console.Out"); + if (monitor.WriteToConsole) + this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); + } + + // add headers + if (this.Settings.DeveloperMode) + { + this.Monitor.ShowTraceInConsole = true; +#if !SMAPI_1_x + this.Monitor.ShowFullStampInConsole = true; +#endif + this.Monitor.Log($"You configured SMAPI to run in developer mode. The console may be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + } + if (!this.Settings.CheckForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + if (this.Settings.VerboseLogging) + this.Monitor.Log("Verbose logging enabled.", LogLevel.Trace); + + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + + // load mods + { +#if SMAPI_1_x + this.Monitor.Log("Loading mod metadata..."); +#else + this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); +#endif + ModResolver resolver = new ModResolver(); + + // load manifests + IModMetadata[] mods = resolver.ReadManifests(Constants.ModPath, new JsonHelper(), this.Settings.ModCompatibility, this.Settings.DisabledMods).ToArray(); + resolver.ValidateManifests(mods, Constants.ApiVersion); + + // check for deprecated metadata +#if SMAPI_1_x + IList<Action> deprecationWarnings = new List<Action>(); + foreach (IModMetadata mod in mods.Where(m => m.Status != ModMetadataStatus.Failed)) + { + // missing fields that will be required in SMAPI 2.0 + { + List<string> missingFields = new List<string>(3); + + if (string.IsNullOrWhiteSpace(mod.Manifest.Name)) + missingFields.Add(nameof(IManifest.Name)); + if (mod.Manifest.Version == null || mod.Manifest.Version.ToString() == "0.0") + missingFields.Add(nameof(IManifest.Version)); + if (string.IsNullOrWhiteSpace(mod.Manifest.UniqueID)) + missingFields.Add(nameof(IManifest.UniqueID)); + + if (missingFields.Any()) + deprecationWarnings.Add(() => this.Monitor.Log($"{mod.DisplayName} is missing some manifest fields ({string.Join(", ", missingFields)}) which will be required in an upcoming SMAPI version.", LogLevel.Warn)); + } + + // per-save directories + if ((mod.Manifest as Manifest)?.PerSaveConfigs == true) + { + deprecationWarnings.Add(() => this.DeprecationManager.Warn(mod.DisplayName, $"{nameof(Manifest)}.{nameof(Manifest.PerSaveConfigs)}", "1.0", DeprecationLevel.PendingRemoval)); + try + { + string psDir = Path.Combine(mod.DirectoryPath, "psconfigs"); + Directory.CreateDirectory(psDir); + if (!Directory.Exists(psDir)) + mod.SetStatus(ModMetadataStatus.Failed, "it requires per-save configuration files ('psconfigs') which couldn't be created for some reason."); + } + catch (Exception ex) + { + mod.SetStatus(ModMetadataStatus.Failed, $"it requires per-save configuration files ('psconfigs') which couldn't be created: {ex.GetLogSummary()}"); + } + } + } +#endif + + // process dependencies + mods = resolver.ProcessDependencies(mods).ToArray(); + + // load mods +#if SMAPI_1_x + this.LoadMods(mods, new JsonHelper(), this.ContentManager, deprecationWarnings); + foreach (Action warning in deprecationWarnings) + warning(); +#else + this.LoadMods(mods, new JsonHelper(), this.ContentManager); +#endif + } + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); + return; + } + + // update window titles + int modsLoaded = this.ModRegistry.GetMods().Count(); + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; + + // start SMAPI console + 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 console +#if SMAPI_1_x + this.Monitor.Log("Starting console..."); +#endif + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + 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 (!this.CommandManager.Trigger(input)) + this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + } + catch (Exception ex) + { + this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + }); + inputThread.Start(); + + // keep console thread alive while the game is running + while (this.IsGameRunning && !this.Monitor.IsExiting) + Thread.Sleep(1000 / 10); + if (inputThread.ThreadState == ThreadState.Running) + inputThread.Abort(); + } + + /// <summary>Look for common issues with the game's XNB content, and log warnings if anything looks broken or outdated.</summary> + /// <returns>Returns whether all integrity checks passed.</returns> + private bool ValidateContentIntegrity() + { + this.Monitor.Log("Detecting common issues...", LogLevel.Trace); + bool issuesFound = false; + + // object format (commonly broken by outdated files) + { + // detect issues + bool hasObjectIssues = false; + void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue}).", LogLevel.Trace); + foreach (KeyValuePair<int, string> entry in Game1.objectInformation) + { + // must not be empty + if (string.IsNullOrWhiteSpace(entry.Value)) + { + LogIssue(entry.Key, "entry is empty"); + hasObjectIssues = true; + continue; + } + + // require core fields + string[] fields = entry.Value.Split('/'); + if (fields.Length < SObject.objectInfoDescriptionIndex + 1) + { + LogIssue(entry.Key, "too few fields for an object"); + hasObjectIssues = true; + continue; + } + + // check min length for specific types + switch (fields[SObject.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + { + case "Cooking": + if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) + { + LogIssue(entry.Key, "too few fields for a cooking item"); + hasObjectIssues = true; + } + break; + } + } + + // log error + if (hasObjectIssues) + { + issuesFound = true; + this.Monitor.Log(@"Your Content\Data\ObjectInformation.xnb file seems to be broken or outdated.", LogLevel.Warn); + } + } + + return !issuesFound; + } + + /// <summary>Asynchronously check for a new version of SMAPI, and print a message to the console if an update is available.</summary> + private void CheckForUpdateAsync() + { + if (!this.Settings.CheckForUpdates) + return; + + new Thread(() => + { + try + { + GitRelease release = UpdateHelper.GetLatestVersionAsync(Constants.GitHubRepository).Result; + ISemanticVersion latestVersion = new SemanticVersion(release.Tag); + if (latestVersion.IsNewerThan(Constants.ApiVersion)) + this.Monitor.Log($"You can update SMAPI from version {Constants.ApiVersion} to {latestVersion}", LogLevel.Alert); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.\n{ex.GetLogSummary()}"); + } + }).Start(); + } + + /// <summary>Create a directory path if it doesn't exist.</summary> + /// <param name="path">The directory path.</param> + private void VerifyPath(string path) + { + try + { + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + + /// <summary>Load and hook up the given mods.</summary> + /// <param name="mods">The mods to load.</param> + /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> + /// <param name="contentManager">The content manager to use for mod content.</param> +#if SMAPI_1_x + /// <param name="deprecationWarnings">A list to populate with any deprecation warnings.</param> + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager, IList<Action> deprecationWarnings) +#else + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, SContentManager contentManager) +#endif + { +#if SMAPI_1_x + this.Monitor.Log("Loading mods..."); +#else + this.Monitor.Log("Loading mods...", LogLevel.Trace); +#endif + // load mod assemblies + IDictionary<IModMetadata, string> skippedMods = new Dictionary<IModMetadata, string>(); + { + void TrackSkip(IModMetadata mod, string reasonPhrase) => skippedMods[mod] = reasonPhrase; + + AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.TargetPlatform, this.Monitor); + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => modAssemblyLoader.ResolveAssembly(e.Name); + foreach (IModMetadata metadata in mods) + { + // get basic info + IManifest manifest = metadata.Manifest; + string assemblyPath = metadata.Manifest?.EntryDll != null + ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) + : null; + this.Monitor.Log(assemblyPath != null + ? $"Loading {metadata.DisplayName} from {assemblyPath.Replace(Constants.ModPath, "").TrimStart(Path.DirectorySeparatorChar)}..." + : $"Loading {metadata.DisplayName}...", LogLevel.Trace); + + // validate status + if (metadata.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); + TrackSkip(metadata, metadata.Error); + continue; + } + + // preprocess & load mod assembly + Assembly modAssembly; + try + { + modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.Compatibility?.Compatibility == ModCompatibilityType.AssumeCompatible); + } + catch (IncompatibleInstructionException ex) + { +#if SMAPI_1_x + TrackSkip(metadata, $"it's not compatible with the latest version of the game or SMAPI (detected {ex.NounPhrase}). Please check for a newer version of the mod."); +#else + TrackSkip(metadata, $"it's no longer compatible (detected {ex.NounPhrase}). Please check for a newer version of the mod."); +#endif + continue; + } + catch (SAssemblyLoadFailedException ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded: {ex.Message}"); + continue; + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL '{manifest.EntryDll}' couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // validate assembly + try + { + int modEntries = modAssembly.DefinedTypes.Count(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + if (modEntries == 0) + { + TrackSkip(metadata, $"its DLL has no '{nameof(Mod)}' subclass."); + continue; + } + if (modEntries > 1) + { + TrackSkip(metadata, $"its DLL contains multiple '{nameof(Mod)}' subclasses."); + continue; + } + } + catch (Exception ex) + { + TrackSkip(metadata, $"its DLL couldn't be loaded:\n{ex.GetLogSummary()}"); + continue; + } + + // initialise mod + try + { + // get implementation + TypeInfo modEntryType = modAssembly.DefinedTypes.First(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract); + Mod mod = (Mod)modAssembly.CreateInstance(modEntryType.ToString()); + if (mod == null) + { + TrackSkip(metadata, "its entry class couldn't be instantiated."); + continue; + } + +#if SMAPI_1_x + // prevent mods from using SMAPI 2.0 content interception before release + // ReSharper disable SuspiciousTypeConversion.Global + if (mod is IAssetEditor || mod is IAssetLoader) + { + TrackSkip(metadata, $"its entry class implements {nameof(IAssetEditor)} or {nameof(IAssetLoader)}. These are part of a prototype API that isn't available for mods to use yet."); + } + // ReSharper restore SuspiciousTypeConversion.Global +#endif + + // inject data + { + IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); + ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentManager, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentManager.GetLocale(), contentManager.GetCurrentLanguage()); + + mod.ModManifest = manifest; + mod.Helper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, translationHelper); + mod.Monitor = monitor; +#if SMAPI_1_x + mod.PathOnDisk = metadata.DirectoryPath; +#endif + } + + // track mod + metadata.SetMod(mod); + this.ModRegistry.Add(metadata); + } + catch (Exception ex) + { + TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); + } + } + } + IModMetadata[] loadedMods = this.ModRegistry.GetMods().ToArray(); + + // log skipped mods +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + if (skippedMods.Any()) + { + this.Monitor.Log($"Skipped {skippedMods.Count} mods:", LogLevel.Error); + foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) + { + IModMetadata mod = pair.Key; + string reason = pair.Value; + + if (mod.Manifest?.Version != null) + this.Monitor.Log($" {mod.DisplayName} {mod.Manifest.Version} because {reason}", LogLevel.Error); + else + this.Monitor.Log($" {mod.DisplayName} because {reason}", LogLevel.Error); + } +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + } + + // log loaded mods + this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); + foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } +#if !SMAPI_1_x + this.Monitor.Newline(); +#endif + + // initialise translations + this.ReloadTranslations(); + + // initialise loaded mods + foreach (IModMetadata metadata in loadedMods) + { + // add interceptors + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + this.ContentManager.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentManager.Loaders[metadata] = helper.ObservableAssetLoaders; + } + + // call entry method + try + { + IMod mod = metadata.Mod; + mod.Entry(mod.Helper); +#if SMAPI_1_x + (mod as Mod)?.Entry(); // deprecated since 1.0 + + // raise deprecation warning for old Entry() methods + if (this.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(object[]) })) + deprecationWarnings.Add(() => this.DeprecationManager.Warn(metadata.DisplayName, $"{nameof(Mod)}.{nameof(Mod.Entry)}(object[]) instead of {nameof(Mod)}.{nameof(Mod.Entry)}({nameof(IModHelper)})", "1.0", DeprecationLevel.PendingRemoval)); +#else + if (!this.DeprecationManager.IsVirtualMethodImplemented(mod.GetType(), typeof(Mod), nameof(Mod.Entry), new[] { typeof(IModHelper) })) + this.Monitor.Log($"{metadata.DisplayName} doesn't implement Entry() and may not work correctly.", LogLevel.Error); +#endif + } + catch (Exception ex) + { + this.Monitor.Log($"{metadata.DisplayName} failed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + } + + // invalidate cache entries when needed + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + foreach (IModMetadata metadata in loadedMods) + { + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + helper.ObservableAssetEditors.CollectionChanged += (sender, e) => + { + if (e.NewItems.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]); + } + }; + helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => + { + if (e.NewItems.Count > 0) + { + this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray()); + } + }; + } + } + + // reset cache now if any editors or loaders were added during entry + IAssetEditor[] editors = loadedMods.SelectMany(p => ((ContentHelper)p.Mod.Helper.Content).AssetEditors).ToArray(); + IAssetLoader[] loaders = loadedMods.SelectMany(p => ((ContentHelper)p.Mod.Helper.Content).AssetLoaders).ToArray(); + if (editors.Any() || loaders.Any()) + { + this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); + this.ContentManager.InvalidateCacheFor(editors, loaders); + } + } + + /// <summary>Reload translations for all mods.</summary> + private void ReloadTranslations() + { + JsonHelper jsonHelper = new JsonHelper(); + foreach (IModMetadata metadata in this.ModRegistry.GetMods()) + { + // 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); + } + } + + /// <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) + { + 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 + { +#if SMAPI_1_x + 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); +#else + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.ModName, command.Name group command.Name by command.ModName).ToArray(); + foreach (var group in groups) + { + string modName = group.Key; + string[] commandNames = group.ToArray(); + message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; + } + message += "For more information about a command, type 'help command_name'."; + + this.Monitor.Log(message, LogLevel.Info); +#endif + } + 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}'."); + } + } + + /// <summary>Redirect messages logged directly to the console to the given monitor.</summary> + /// <param name="monitor">The monitor with which to log messages.</param> + /// <param name="message">The message to log.</param> + private void HandleConsoleMessage(IMonitor monitor, string message) + { + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; // intercept potential exceptions + monitor.Log(message, level); + } + + /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> + 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); + } + + /// <summary>Get a monitor instance derived from SMAPI's current settings.</summary> + /// <param name="name">The name of the module which will log messages with this instance.</param> + private Monitor GetSecondaryMonitor(string name) + { + return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource) + { + WriteToConsole = this.Monitor.WriteToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, +#if !SMAPI_1_x + ShowFullStampInConsole = this.Settings.DeveloperMode +#endif + }; + } + + /// <summary>Get a human-readable name for the current platform.</summary> + [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] + private string GetFriendlyPlatformName() + { +#if SMAPI_FOR_WINDOWS + try + { + return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + .Get() + .Cast<ManagementObject>() + .Select(entry => entry.GetPropertyValue("Caption").ToString()) + .FirstOrDefault(); + } + catch { } +#endif + return Environment.OSVersion.ToString(); + } + } +} diff --git a/src/StardewModdingAPI/Properties/AssemblyInfo.cs b/src/StardewModdingAPI/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..b0a065f5 --- /dev/null +++ b/src/StardewModdingAPI/Properties/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Stardew Modding API (SMAPI)")] +[assembly: AssemblyDescription("A modding API for Stardew Valley.")] +[assembly: Guid("5c3f7f42-fefd-43db-aaea-92ea3bcad531")] +[assembly: InternalsVisibleTo("StardewModdingAPI.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/StardewModdingAPI/SemanticVersion.cs b/src/StardewModdingAPI/SemanticVersion.cs new file mode 100644 index 00000000..e448eae1 --- /dev/null +++ b/src/StardewModdingAPI/SemanticVersion.cs @@ -0,0 +1,239 @@ +using System; +using System.Text.RegularExpressions; +using Newtonsoft.Json; + +namespace StardewModdingAPI +{ + /// <summary>A semantic version with an optional release tag.</summary> + public class SemanticVersion : ISemanticVersion + { + /********* + ** Properties + *********/ + /// <summary>A regular expression matching a semantic version string.</summary> + /// <remarks> + /// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, + /// with three important deviations intended to support Stardew Valley mod conventions: + /// - allows short-form "x.y" versions; + /// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3"); + /// - doesn't allow '+build' suffixes. + /// </remarks> + private static readonly Regex Regex = new Regex(@"^(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\-\.]?)+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); + + + /********* + ** Accessors + *********/ + /// <summary>The major version incremented for major API changes.</summary> + public int MajorVersion { get; } + + /// <summary>The minor version incremented for backwards-compatible changes.</summary> + public int MinorVersion { get; } + + /// <summary>The patch version for backwards-compatible bug fixes.</summary> + public int PatchVersion { get; } + + /// <summary>An optional build tag.</summary> + public string Build { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="majorVersion">The major version incremented for major API changes.</param> + /// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param> + /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> + /// <param name="build">An optional build tag.</param> + [JsonConstructor] + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string build = null) + { + this.MajorVersion = majorVersion; + this.MinorVersion = minorVersion; + this.PatchVersion = patchVersion; + this.Build = this.GetNormalisedTag(build); + } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The semantic version string.</param> + /// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception> + /// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception> + public SemanticVersion(string version) + { + // parse + if (version == null) + throw new ArgumentNullException(nameof(version), "The input version string can't be null."); + var match = SemanticVersion.Regex.Match(version.Trim()); + if (!match.Success) + throw new FormatException($"The input '{version}' isn't a valid semantic version."); + + // initialise + this.MajorVersion = int.Parse(match.Groups["major"].Value); + this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; + this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; + this.Build = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; + } + + /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> + /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks> + public int CompareTo(ISemanticVersion other) + { + if (other == null) + throw new ArgumentNullException(nameof(other)); + + const int same = 0; + const int curNewer = 1; + const int curOlder = -1; + + // compare stable versions + if (this.MajorVersion != other.MajorVersion) + return this.MajorVersion.CompareTo(other.MajorVersion); + if (this.MinorVersion != other.MinorVersion) + return this.MinorVersion.CompareTo(other.MinorVersion); + if (this.PatchVersion != other.PatchVersion) + return this.PatchVersion.CompareTo(other.PatchVersion); + if (this.Build == other.Build) + return same; + + // stable supercedes pre-release + bool curIsStable = string.IsNullOrWhiteSpace(this.Build); + bool otherIsStable = string.IsNullOrWhiteSpace(other.Build); + if (curIsStable) + return curNewer; + if (otherIsStable) + return curOlder; + + // compare two pre-release tag values + string[] curParts = this.Build.Split('.', '-'); + string[] otherParts = other.Build.Split('.', '-'); + for (int i = 0; i < curParts.Length; i++) + { + // longer prerelease tag supercedes if otherwise equal + if (otherParts.Length <= i) + return curNewer; + + // compare if different + if (curParts[i] != otherParts[i]) + { + // compare numerically if possible + { + if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) + return curNum.CompareTo(otherNum); + } + + // else compare lexically + return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase); + } + } + + // fallback (this should never happen) + return string.Compare(this.ToString(), other.ToString(), StringComparison.InvariantCultureIgnoreCase); + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsOlderThan(ISemanticVersion other) + { + return this.CompareTo(other) < 0; + } + + /// <summary>Get whether this version is older than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsOlderThan(string other) + { + return this.IsOlderThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + public bool IsNewerThan(ISemanticVersion other) + { + return this.CompareTo(other) > 0; + } + + /// <summary>Get whether this version is newer than the specified version.</summary> + /// <param name="other">The version to compare with this instance.</param> + /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> + public bool IsNewerThan(string other) + { + return this.IsNewerThan(new SemanticVersion(other)); + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + public bool IsBetween(ISemanticVersion min, ISemanticVersion max) + { + return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; + } + + /// <summary>Get whether this version is between two specified versions (inclusively).</summary> + /// <param name="min">The minimum version.</param> + /// <param name="max">The maximum version.</param> + /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> + public bool IsBetween(string min, string max) + { + return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max)); + } + +#if !SMAPI_1_x + /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> + /// <returns>true if the current object is equal to the <paramref name="other" /> parameter; otherwise, false.</returns> + /// <param name="other">An object to compare with this object.</param> + public bool Equals(ISemanticVersion other) + { + return other != null && this.CompareTo(other) == 0; + } +#endif + + /// <summary>Get a string representation of the version.</summary> + public override string ToString() + { + // version + string result = this.PatchVersion != 0 + ? $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}" + : $"{this.MajorVersion}.{this.MinorVersion}"; + + // tag + string tag = this.Build; + if (tag != null) + result += $"-{tag}"; + return result; + } + + /// <summary>Parse a version string without throwing an exception if it fails.</summary> + /// <param name="version">The version string.</param> + /// <param name="parsed">The parsed representation.</param> + /// <returns>Returns whether parsing the version succeeded.</returns> + internal static bool TryParse(string version, out ISemanticVersion parsed) + { + try + { + parsed = new SemanticVersion(version); + return true; + } + catch + { + parsed = null; + return false; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a normalised build tag.</summary> + /// <param name="tag">The tag to normalise.</param> + private string GetNormalisedTag(string tag) + { + tag = tag?.Trim(); + if (string.IsNullOrWhiteSpace(tag) || tag == "0") // '0' from incorrect examples in old SMAPI documentation + return null; + return tag; + } + } +} diff --git a/src/StardewModdingAPI/StardewModdingAPI.config.json b/src/StardewModdingAPI/StardewModdingAPI.config.json new file mode 100644 index 00000000..d393f5a9 --- /dev/null +++ b/src/StardewModdingAPI/StardewModdingAPI.config.json @@ -0,0 +1,490 @@ +/* + + + +This file contains advanced configuration for SMAPI. You generally shouldn't change this file. + + + +*/ +{ + /** + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": true, + + /** + * Whether SMAPI should check for a newer version when you load the game. If a new version is + * available, a small message will appear in the console. This doesn't affect the load time even + * if your connection is offline or slow, because it happens in the background. + */ + "CheckForUpdates": true, + + /** + * Whether SMAPI should log more information about the game context. + */ + "VerboseLogging": false, + + /** + * A list of mods SMAPI should consider obsolete and not load. Changing this field is not + * recommended and may destabilise your game. + */ + "DisabledMods": [ + { + "Name": "Animal Mood Fix", + "ID": [ "GPeters-AnimalMoodFix" ], + "ReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + }, + { + "Name": "Colored Chests", + "ID": [ "4befde5c-731c-4853-8e4b-c5cdf946805f" ], + "ReasonPhrase": "colored chests were added in Stardew Valley 1.1." + }, + { + "Name": "Modder Serialization Utility", + "ID": [ "SerializerUtils-0-1" ], + "ReasonPhrase": "it's no longer maintained or used." + }, + { + "Name": "No Debug Mode", + "ID": [ "NoDebugMode" ], + "ReasonPhrase": "debug mode was removed in SMAPI 1.0." + }, + { + "Name": "StarDustCore", + "ID": [ "StarDustCore" ], + "ReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained." + }, + { + "Name": "XmlSerializerRetool", + "ID": [ "XmlSerializerRetool.dll" ], + "ReasonPhrase": "it's no longer maintained or used." + } + ], + + /** + * A list of mod versions SMAPI should consider compatible or broken regardless of whether it + * detects incompatible code. The default for each record is to assume broken; to force SMAPI to + * load a mod regardless of compatibility checks, add a "Compatibility": "AssumeCompatible" field. + * Changing this field is not recommended and may destabilise your game. + */ + "ModCompatibility": [ + { + "Name": "AccessChestAnywhere", + "ID": [ "AccessChestAnywhere" ], + "UpperVersion": "1.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/257", "http://www.nexusmods.com/stardewvalley/mods/518" ], + "Notes": "Broke in SDV 1.1." + }, + { + "Name": "AdjustArtisanPrices", + "ID": [ "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc" ], + "UpperVersion": "0.0", + "UpperVersionLabel": "0.01", + "UpdateUrls": [ "http://community.playstarbound.com/resources/3532", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SMAPI 1.9." + }, + { + "Name": "Advanced Location Loader", + "ID": [ "Entoarox.AdvancedLocationLoader" ], + "UpperVersion": "1.2.10", + "UpdateUrls": [ "http://community.playstarbound.com/resources/3619" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "Almighty Tool", + "ID": [ "AlmightyTool.dll" ], + "UpperVersion": "1.1.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/439" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Better Sprinklers", + "ID": [ "SPDSprinklersMod", /*since 2.3*/ "Speeder.BetterSprinklers" ], + "UpperVersion": "2.3.1-pathoschild-update", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/41", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Birthday Mail", + "ID": [ "005e02dc-d900-425c-9c68-1ff55c5a295d" ], + "UpperVersion": "1.2.2", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/276", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Chest Label System", + "ID": [ "SPDChestLabel" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/242", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.1." + }, + { + "Name": "Chest Pooling", + "ID": [ "ChestPooling.dll" ], + "UpperVersion": "1.2", + "UpdateUrls": [ "http://community.playstarbound.com/threads/111988" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Chests Anywhere", + "ID": [ "ChestsAnywhere", /*since 1.9*/ "Pathoschild.ChestsAnywhere" ], + "UpperVersion": "1.9-beta", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/518" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "CJB Automation", + "ID": [ "CJBAutomation" ], + "UpperVersion": "1.4", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/211", "http://www.nexusmods.com/stardewvalley/mods/1063" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "CJB Cheats Menu", + "ID": [ "CJBCheatsMenu" ], + "UpperVersion": "1.12", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/4" ], + "Notes": "Broke in SDV 1.1." + }, + { + "Name": "CJB Item Spawner", + "ID": [ "CJBItemSpawner" ], + "UpperVersion": "1.5", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/93" ], + "Notes": "Broke in SDV 1.1." + }, + { + "Name": "CJB Show Item Sell Price", + "ID": [ "CJBShowItemSellPrice" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/93" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Cooking Skill", + "ID": [ "CookingSkill" ], + "UpperVersion": "1.0.3", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/522" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Enemy Health Bars", + "ID": [ "SPDHealthBar" ], + "UpperVersion": "1.7", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/193", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Entoarox Framework", + "ID": [ "eacdb74b-4080-4452-b16b-93773cda5cf9", /*since ???*/ "Entoarox.EntoaroxFramework" ], + "UpperVersion": "1.7.10", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4228" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "Extended Fridge", + "ID": [ "Mystra007ExtendedFridge" ], + "UpperVersion": "1.0", + "UpperVersionLabel": "0.94", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/485" ], + "Notes": "Broke in SDV 1.2. Actual upper version is 0.94, but mod incorrectly sets it to 1.0 in the manifest." + }, + { + "Name": "Farm Automation: Item Collector", + "ID": [ "FarmAutomation.ItemCollector.dll" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://community.playstarbound.com/threads/111931", "http://community.playstarbound.com/threads/125172" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Farm Automation Unofficial: Item Collector", + "ID": [ "Maddy99.FarmAutomation.ItemCollector" ], + "UpperVersion": "0.4", + "UpdateUrls": [ "http://community.playstarbound.com/threads/125172" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Instant Geode", + "ID": [ "InstantGeode" ], + "UpperVersion": "1.12", + "UpdateUrls": [ "http://community.playstarbound.com/threads/109038", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Gate Opener", + "ID": [ "GateOpener.dll", /*since 1.1*/ "mralbobo.GateOpener" ], + "UpperVersion": "1.0.1", + "UpdateUrls": [ "http://community.playstarbound.com/threads/111988" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Get Dressed", + "ID": [ "GetDressed.dll", /*since 3.3*/ "Advize.GetDressed" ], + "UpperVersion": "3.3", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/331" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Gift Taste Helper", + "ID": [ "8008db57-fa67-4730-978e-34b37ef191d6" ], + "UpperVersion": "2.3.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/229" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Lookup Anything", + "ID": [ "LookupAnything", /*since 1.10.1*/ "Pathoschild.LookupAnything" ], + "UpperVersion": "1.10.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/541" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Makeshift Multiplayer", + "ID": [ "StardewValleyMP", /*since 0.3*/ "spacechase0.StardewValleyMP" ], + "UpperVersion": "0.3.3", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/501" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "More Pets", + "ID": [ "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS", /* since 1.3 */ "Entoarox.MorePets" ], + "UpperVersion": "1.3.2", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4288" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "NoSoilDecay", + "ID": [ "289dee03-5f38-4d8e-8ffc-e440198e8610" ], + "UpperVersion": "0.5", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/237", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2, and uses Assembly.GetExecutingAssembly().Location." + }, + { + "Name": "NPC Map Locations", + "ID": [ "NPCMapLocationsMod" ], + "LowerVersion": "1.42", + "UpperVersion": "1.43", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/239" ], + "ReasonPhrase": "this version has an update check error which crashes the game." + }, + { + "Name": "Part of the Community", + "ID": [ "SB_PotC" ], + "UpperVersion": "1.0.8", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/923" ], + "ReasonPhrase": "Broke in SDV 1.2." + }, + { + "Name": "Persival's BundleMod", + "ID": [ "BundleMod.dll" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/438", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.1." + }, + { + "Name": "Point-and-Plant", + "ID": [ "PointAndPlant.dll" ], + "UpperVersion": "1.0.2", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/572" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "PrairieKingMadeEasy", + "ID": [ "PrairieKingMadeEasy.dll" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://community.playstarbound.com/resources/3594", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Rush Orders", + "ID": [ "RushOrders", /*since 1.1*/ "spacechase0.RushOrders" ], + "UpperVersion": "1.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/605" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Save Anywhere", + "ID": [ "{'ID':'SaveAnywhere', 'Name':'Save Anywhere'}" ], // disambiguate from Night Owl + "UpperVersion": "2.3", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/444" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Seasonal Immersion", + "ID": [ "EntoaroxSeasonalHouse", /*since 1.1.0*/ "EntoaroxSeasonalBuildings", /* since 1.6 or earlier*/ "EntoaroxSeasonalImmersion", /*since 1.7*/ "Entoarox.SeasonalImmersion" ], + "UpperVersion": "1.8.2", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4262" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "Shop Expander", + "ID": [ /*since at least 1.4*/ "821ce8f6-e629-41ad-9fde-03b54f68b0b6", /*since 1.5*/ "EntoaroxShopExpander", /*since 1.5.2*/ "Entoarox.ShopExpander" ], + "UpperVersion": "1.5.3", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4381" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "Simple Sprinklers", + "ID": [ "SimpleSprinkler.dll" ], + "UpperVersion": "1.4", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/76" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Siv's Marriage Mod", + "ID": [ "6266959802" ], + "UpperVersion": "1.2.2", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/366", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SMAPI 1.9 (has multiple Mod instances)." + }, + { + "Name": "Skill Prestige: Cooking Adapter", + "ID": [ "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63" ], + "UpperVersion": "1.0.4", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/569", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Sprint and Dash", + "ID": [ "SPDSprintAndDash" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://community.playstarbound.com/resources/3531", "http://community.playstarbound.com/resources/4201" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Sprint and Dash Redux", + "ID": [ "SPDSprintAndDash" ], + "UpperVersion": "1.2", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4201" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Sprinting Mod", + "ID": [ "a10d3097-b073-4185-98ba-76b586cba00c" ], + "UpperVersion": "2.1", + "UpdateUrls": [ "http://community.playstarbound.com/threads/108313", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "StackSplitX", + "ID": [ "StackSplitX.dll" ], + "UpperVersion": "1.2", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/798" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Tainted Cellar", + "ID": [ "TaintedCellar.dll" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://community.playstarbound.com/threads/115735", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.1 or 1.11." + }, + { + "Name": "Teleporter", + "ID": [ "Teleporter" ], + "UpperVersion": "1.0.2", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4374" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Three-heart Dance Partner", + "ID": [ "ThreeHeartDancePartner" ], + "UpperVersion": "1.0.1", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/500", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "TimeSpeed", + "ID": [ "TimeSpeed.dll", /* since 2.0.3 */ "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed'}", /* since 2.0.3 */ "{ID:'4108e859-333c-4fec-a1a7-d2e18c1019fe', Name:'TimeSpeed Mod (unofficial)'}", /*since 2.1*/ "community.TimeSpeed" ], // disambiguate other mods by Alpha_Omegasis + "UpperVersion": "2.2", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/169" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "UiModSuite", + "ID": [ "Demiacle.UiModSuite" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://www.nexusmods.com/stardewvalley/mods/1023" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Weather Controller", + "ID": [ "WeatherController.dll" ], + "UpperVersion": "1.0.2", + "UpdateUrls": [ "http://community.playstarbound.com/threads/111526", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Wonderful Farm Life", + "ID": [ "WonderfulFarmLife.dll" ], + "UpperVersion": "1.0", + "UpdateUrls": [ "http://community.playstarbound.com/threads/115384", "http://community.playstarbound.com/threads/132096" ], + "Notes": "Broke in SDV 1.1 or 1.11." + }, + { + "Name": "Xnb Loader", + "ID": [ "Entoarox.XnbLoader" ], + "UpperVersion": "1.0.6", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4506" ], + "Notes": "Overhauled for SMAPI 1.11+ compatibility." + }, + { + "Name": "zDailyIncrease", + "ID": [ "zdailyincrease" ], + "UpperVersion": "1.2", + "UpdateUrls": [ "http://community.playstarbound.com/resources/4247" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoom Out Extreme", + "ID": [ "ZoomMod" ], + "UpperVersion": "0.1", + "UpdateUrls": [ "http://community.playstarbound.com/threads/115028" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Better RNG", + "ID": [ "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", /*since 1.6*/ "Zoryn.BetterRNG" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Calendar Anywhere", + "ID": [ "a41c01cd-0437-43eb-944f-78cb5a53002a", /*since 1.6*/ "Zoryn.CalendarAnywhere" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Health Bars", + "ID": [ "HealthBars.dll", /*since 1.6*/ "Zoryn.HealthBars" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Junimo Deposit Anywhere", + "ID": [ "f93a4fe8-cade-4146-9335-b5f82fbbf7bc", /*since 1.6*/ "Zoryn.JunimoDepositAnywhere" ], + "UpperVersion": "1.7", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Movement Mod", + "ID": [ "8a632929-8335-484f-87dd-c29d2ba3215d", /*since 1.6*/ "Zoryn.MovementModifier" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + }, + { + "Name": "Zoryn's Regen Mod", + "ID": [ "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", /*since 1.6*/ "Zoryn.RegenMod" ], + "UpperVersion": "1.6", + "UpdateUrls": [ "https://github.com/Zoryn4163/SMAPI-Mods/releases" ], + "Notes": "Broke in SDV 1.2." + } + ] +} diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj new file mode 100644 index 00000000..8da93bf4 --- /dev/null +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -0,0 +1,279 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">x86</Platform> + <ProjectGuid>{F1A573B0-F436-472C-AE29-0B91EA6B9F8F}</ProjectGuid> + <OutputType>Exe</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>StardewModdingAPI</RootNamespace> + <AssemblyName>StardewModdingAPI</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + <IsWebBootstrapper>false</IsWebBootstrapper> + <TargetFrameworkProfile /> + <PublishUrl>publish\</PublishUrl> + <Install>true</Install> + <InstallFrom>Disk</InstallFrom> + <UpdateEnabled>false</UpdateEnabled> + <UpdateMode>Foreground</UpdateMode> + <UpdateInterval>7</UpdateInterval> + <UpdateIntervalUnits>Days</UpdateIntervalUnits> + <UpdatePeriodically>false</UpdatePeriodically> + <UpdateRequired>false</UpdateRequired> + <MapFileExtensions>true</MapFileExtensions> + <ApplicationRevision>0</ApplicationRevision> + <ApplicationVersion>1.0.0.%2a</ApplicationVersion> + <UseApplicationTrust>false</UseApplicationTrust> + <BootstrapperEnabled>true</BootstrapperEnabled> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'"> + <PlatformTarget>x86</PlatformTarget> + <Prefer32Bit>false</Prefer32Bit> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <UseVSHostingProcess>true</UseVSHostingProcess> + <Optimize>false</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Debug\SMAPI</OutputPath> + <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + </PropertyGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'"> + <PlatformTarget>x86</PlatformTarget> + <Prefer32Bit>false</Prefer32Bit> + <OutputPath>$(SolutionDir)\..\bin\Release\SMAPI</OutputPath> + <DocumentationFile>$(SolutionDir)\..\bin\Debug\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <DefineConstants>TRACE</DefineConstants> + <Optimize>true</Optimize> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + <DebugType>pdbonly</DebugType> + <DebugSymbols>true</DebugSymbols> + </PropertyGroup> + <PropertyGroup> + <ApplicationIcon>icon.ico</ApplicationIcon> + </PropertyGroup> + <ItemGroup> + <Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Mdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL"> + <HintPath>..\packages\Mono.Cecil.0.9.6.4\lib\net45\Mono.Cecil.Pdb.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> + <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> + <Private>True</Private> + </Reference> + <Reference Include="System" /> + <Reference Include="System.Core" /> + <Reference Include="System.Drawing" /> + <Reference Include="System.Management" Condition="$(OS) == 'Windows_NT'" /> + <Reference Include="System.Numerics"> + <Private>True</Private> + </Reference> + <Reference Include="System.Runtime.Caching"> + <Private>True</Private> + </Reference> + <Reference Include="System.Windows.Forms" Condition="$(OS) == 'Windows_NT'" /> + <Reference Include="System.Xml.Linq" /> + <Reference Include="System.Data.DataSetExtensions" /> + <Reference Include="Microsoft.CSharp" /> + <Reference Include="System.Data" /> + <Reference Include="System.Xml" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="Command.cs" /> + <Compile Include="Framework\ModLoading\Finders\EventFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\FieldFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\MethodFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\PropertyFinder.cs" /> + <Compile Include="Framework\ModLoading\Finders\TypeFinder.cs" /> + <Compile Include="Framework\ModLoading\IInstructionHandler.cs" /> + <Compile Include="Framework\ModLoading\IncompatibleInstructionException.cs" /> + <Compile Include="Framework\ModLoading\InstructionHandleResult.cs" /> + <Compile Include="Framework\ModLoading\Platform.cs" /> + <Compile Include="Framework\ModLoading\PlatformAssemblyMap.cs" /> + <Compile Include="Framework\ModLoading\RewriteHelper.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\FieldReplaceRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\FieldToPropertyRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" /> + <Compile Include="Framework\ModLoading\Rewriters\Wrappers\SpriteBatchWrapper.cs" /> + <Compile Include="Framework\ContentManagerShim.cs" /> + <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> + <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> + <Compile Include="Framework\Utilities\ContextHash.cs" /> + <Compile Include="Metadata\CoreAssets.cs" /> + <Compile Include="ContentSource.cs" /> + <Compile Include="Events\ContentEvents.cs" /> + <Compile Include="Events\EventArgsInput.cs" /> + <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Events\InputEvents.cs" /> + <Compile Include="Framework\Content\AssetInfo.cs" /> + <Compile Include="Framework\Exceptions\SContentLoadException.cs" /> + <Compile Include="Framework\Command.cs" /> + <Compile Include="Config.cs" /> + <Compile Include="Constants.cs" /> + <Compile Include="Events\ControlEvents.cs" /> + <Compile Include="Events\EventArgsCommand.cs" /> + <Compile Include="Events\EventArgsClickableMenuChanged.cs" /> + <Compile Include="Events\EventArgsClickableMenuClosed.cs" /> + <Compile Include="Events\EventArgsControllerButtonPressed.cs" /> + <Compile Include="Events\EventArgsControllerButtonReleased.cs" /> + <Compile Include="Events\EventArgsControllerTriggerPressed.cs" /> + <Compile Include="Events\EventArgsControllerTriggerReleased.cs" /> + <Compile Include="Events\EventArgsCurrentLocationChanged.cs" /> + <Compile Include="Events\EventArgsFarmerChanged.cs" /> + <Compile Include="Events\EventArgsGameLocationsChanged.cs" /> + <Compile Include="Events\EventArgsIntChanged.cs" /> + <Compile Include="Events\EventArgsInventoryChanged.cs" /> + <Compile Include="Events\EventArgsKeyboardStateChanged.cs" /> + <Compile Include="Events\EventArgsKeyPressed.cs" /> + <Compile Include="Events\EventArgsLevelUp.cs" /> + <Compile Include="Events\EventArgsLoadedGameChanged.cs" /> + <Compile Include="Events\EventArgsLocationObjectsChanged.cs" /> + <Compile Include="Events\EventArgsMineLevelChanged.cs" /> + <Compile Include="Events\EventArgsMouseStateChanged.cs" /> + <Compile Include="Events\EventArgsNewDay.cs" /> + <Compile Include="Events\EventArgsStringChanged.cs" /> + <Compile Include="Events\GameEvents.cs" /> + <Compile Include="Events\GraphicsEvents.cs" /> + <Compile Include="Framework\Utilities\Countdown.cs" /> + <Compile Include="Framework\GameVersion.cs" /> + <Compile Include="Framework\IModMetadata.cs" /> + <Compile Include="Framework\Models\DisabledMod.cs" /> + <Compile Include="Framework\Models\ModCompatibilityID.cs" /> + <Compile Include="Framework\ModHelpers\BaseHelper.cs" /> + <Compile Include="Framework\ModHelpers\CommandHelper.cs" /> + <Compile Include="Framework\ModHelpers\ContentHelper.cs" /> + <Compile Include="Framework\ModHelpers\ModHelper.cs" /> + <Compile Include="Framework\ModHelpers\ModRegistryHelper.cs" /> + <Compile Include="Framework\ModHelpers\ReflectionHelper.cs" /> + <Compile Include="Framework\ModHelpers\TranslationHelper.cs" /> + <Compile Include="Framework\ModLoading\InvalidModStateException.cs" /> + <Compile Include="Framework\ModLoading\ModDependencyStatus.cs" /> + <Compile Include="Framework\ModLoading\ModMetadataStatus.cs" /> + <Compile Include="Framework\ModLoading\ModResolver.cs" /> + <Compile Include="Framework\ModLoading\AssemblyDefinitionResolver.cs" /> + <Compile Include="Framework\ModLoading\AssemblyParseResult.cs" /> + <Compile Include="Framework\CommandManager.cs" /> + <Compile Include="Framework\Content\AssetData.cs" /> + <Compile Include="Framework\Content\AssetDataForObject.cs" /> + <Compile Include="Framework\Content\AssetDataForDictionary.cs" /> + <Compile Include="Framework\Content\AssetDataForImage.cs" /> + <Compile Include="Context.cs" /> + <Compile Include="Framework\Logging\ConsoleInterceptionManager.cs" /> + <Compile Include="Framework\Logging\InterceptingTextWriter.cs" /> + <Compile Include="Framework\Models\ManifestDependency.cs" /> + <Compile Include="Framework\Models\ModCompatibilityType.cs" /> + <Compile Include="Framework\Models\SConfig.cs" /> + <Compile Include="Framework\ModLoading\ModMetadata.cs" /> + <Compile Include="Framework\Reflection\PrivateProperty.cs" /> + <Compile Include="Framework\RequestExitDelegate.cs" /> + <Compile Include="Framework\SContentManager.cs" /> + <Compile Include="Framework\Exceptions\SParseException.cs" /> + <Compile Include="Framework\Serialisation\JsonHelper.cs" /> + <Compile Include="Framework\Serialisation\SelectiveStringEnumConverter.cs" /> + <Compile Include="Framework\Serialisation\SFieldConverter.cs" /> + <Compile Include="IAssetEditor.cs" /> + <Compile Include="IAssetInfo.cs" /> + <Compile Include="IAssetLoader.cs" /> + <Compile Include="ICommandHelper.cs" /> + <Compile Include="IAssetData.cs" /> + <Compile Include="IAssetDataForDictionary.cs" /> + <Compile Include="IAssetDataForImage.cs" /> + <Compile Include="IContentHelper.cs" /> + <Compile Include="IManifestDependency.cs" /> + <Compile Include="IModRegistry.cs" /> + <Compile Include="Events\LocationEvents.cs" /> + <Compile Include="Events\MenuEvents.cs" /> + <Compile Include="Events\MineEvents.cs" /> + <Compile Include="Events\PlayerEvents.cs" /> + <Compile Include="Events\SaveEvents.cs" /> + <Compile Include="Events\TimeEvents.cs" /> + <Compile Include="Framework\DeprecationLevel.cs" /> + <Compile Include="Framework\DeprecationManager.cs" /> + <Compile Include="Framework\InternalExtensions.cs" /> + <Compile Include="Framework\Models\ModCompatibility.cs" /> + <Compile Include="Framework\ModLoading\AssemblyLoader.cs" /> + <Compile Include="Framework\Reflection\CacheEntry.cs" /> + <Compile Include="Framework\Reflection\PrivateField.cs" /> + <Compile Include="Framework\Reflection\PrivateMethod.cs" /> + <Compile Include="Framework\Reflection\Reflector.cs" /> + <Compile Include="IManifest.cs" /> + <Compile Include="IMod.cs" /> + <Compile Include="IModHelper.cs" /> + <Compile Include="IModLinked.cs" /> + <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" /> + <Compile Include="Framework\Models\GitRelease.cs" /> + <Compile Include="IMonitor.cs" /> + <Compile Include="Events\ChangeType.cs" /> + <Compile Include="Events\ItemStackChange.cs" /> + <Compile Include="Log.cs" /> + <Compile Include="Framework\Monitor.cs" /> + <Compile Include="Framework\Models\Manifest.cs" /> + <Compile Include="Metadata\InstructionMetadata.cs" /> + <Compile Include="Mod.cs" /> + <Compile Include="PatchMode.cs" /> + <Compile Include="Program.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + <Compile Include="Framework\SGame.cs" /> + <Compile Include="IPrivateField.cs" /> + <Compile Include="IPrivateMethod.cs" /> + <Compile Include="IReflectionHelper.cs" /> + <Compile Include="SemanticVersion.cs" /> + <Compile Include="Translation.cs" /> + <Compile Include="ICursorPosition.cs" /> + <Compile Include="Utilities\SDate.cs" /> + <Compile Include="Utilities\SButton.cs" /> + <Compile Include="Framework\CursorPosition.cs" /> + </ItemGroup> + <ItemGroup> + <None Include="App.config"> + <SubType>Designer</SubType> + </None> + <None Include="packages.config"> + <SubType>Designer</SubType> + </None> + <Content Include="StardewModdingAPI.config.json"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + <None Include="unix-launcher.sh"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </None> + </ItemGroup> + <ItemGroup> + <Content Include="icon.ico" /> + <Content Include="steam_appid.txt"> + <CopyToOutputDirectory>Always</CopyToOutputDirectory> + </Content> + </ItemGroup> + <ItemGroup> + <BootstrapperPackage Include=".NETFramework,Version=v4.5"> + <Visible>False</Visible> + <ProductName>Microsoft .NET Framework 4.5 %28x86 and x64%29</ProductName> + <Install>true</Install> + </BootstrapperPackage> + <BootstrapperPackage Include="Microsoft.Net.Framework.3.5.SP1"> + <Visible>False</Visible> + <ProductName>.NET Framework 3.5 SP1</ProductName> + <Install>false</Install> + </BootstrapperPackage> + </ItemGroup> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <Import Project="$(SolutionDir)\common.targets" /> +</Project>
\ No newline at end of file diff --git a/src/StardewModdingAPI/Translation.cs b/src/StardewModdingAPI/Translation.cs new file mode 100644 index 00000000..ce344f81 --- /dev/null +++ b/src/StardewModdingAPI/Translation.cs @@ -0,0 +1,154 @@ +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 underlying translation text.</summary> + private readonly string Text; + + /// <summary>The value to return if the translations is undefined.</summary> + private readonly string Placeholder; + + + /********* + ** Accessors + *********/ + /// <summary>The original translation key.</summary> + public string Key { get; } + + + /********* + ** 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/Utilities/SButton.cs b/src/StardewModdingAPI/Utilities/SButton.cs new file mode 100644 index 00000000..33058a64 --- /dev/null +++ b/src/StardewModdingAPI/Utilities/SButton.cs @@ -0,0 +1,685 @@ +using System; +using Microsoft.Xna.Framework.Input; +using StardewValley; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>A unified button constant which includes all controller, keyboard, and mouse buttons.</summary> + /// <remarks>Derived from <see cref="Keys"/>, <see cref="Buttons"/>, and <see cref="System.Windows.Forms.MouseButtons"/>.</remarks> +#if SMAPI_1_x + internal +#else + public +#endif + enum SButton + { + /// <summary>No valid key.</summary> + None = 0, + + /********* + ** Mouse + *********/ + /// <summary>The left mouse button.</summary> + MouseLeft = 1000, + + /// <summary>The right mouse button.</summary> + MouseRight = 1001, + + /// <summary>The middle mouse button.</summary> + MouseMiddle = 1002, + + /// <summary>The first mouse XButton.</summary> + MouseX1 = 1003, + + /// <summary>The second mouse XButton.</summary> + MouseX2 = 1004, + + /********* + ** Controller + *********/ + /// <summary>The 'A' button on a controller.</summary> + ControllerA = SButtonExtensions.ControllerOffset + Buttons.A, + + /// <summary>The 'B' button on a controller.</summary> + ControllerB = SButtonExtensions.ControllerOffset + Buttons.B, + + /// <summary>The 'X' button on a controller.</summary> + ControllerX = SButtonExtensions.ControllerOffset + Buttons.X, + + /// <summary>The 'Y' button on a controller.</summary> + ControllerY = SButtonExtensions.ControllerOffset + Buttons.Y, + + /// <summary>The back button on a controller.</summary> + ControllerBack = SButtonExtensions.ControllerOffset + Buttons.Back, + + /// <summary>The start button on a controller.</summary> + ControllerStart = SButtonExtensions.ControllerOffset + Buttons.Start, + + /// <summary>The up button on the directional pad of a controller.</summary> + DPadUp = SButtonExtensions.ControllerOffset + Buttons.DPadUp, + + /// <summary>The down button on the directional pad of a controller.</summary> + DPadDown = SButtonExtensions.ControllerOffset + Buttons.DPadDown, + + /// <summary>The left button on the directional pad of a controller.</summary> + DPadLeft = SButtonExtensions.ControllerOffset + Buttons.DPadLeft, + + /// <summary>The right button on the directional pad of a controller.</summary> + DPadRight = SButtonExtensions.ControllerOffset + Buttons.DPadRight, + + /// <summary>The left bumper (shoulder) button on a controller.</summary> + LeftShoulder = SButtonExtensions.ControllerOffset + Buttons.LeftShoulder, + + /// <summary>The right bumper (shoulder) button on a controller.</summary> + RightShoulder = SButtonExtensions.ControllerOffset + Buttons.RightShoulder, + + /// <summary>The left trigger on a controller.</summary> + LeftTrigger = SButtonExtensions.ControllerOffset + Buttons.LeftTrigger, + + /// <summary>The right trigger on a controller.</summary> + RightTrigger = SButtonExtensions.ControllerOffset + Buttons.RightTrigger, + + /// <summary>The left analog stick on a controller (when pressed).</summary> + LeftStick = SButtonExtensions.ControllerOffset + Buttons.LeftStick, + + /// <summary>The right analog stick on a controller (when pressed).</summary> + RightStick = SButtonExtensions.ControllerOffset + Buttons.RightStick, + + /// <summary>The 'big button' on a controller.</summary> + BigButton = SButtonExtensions.ControllerOffset + Buttons.BigButton, + + /// <summary>The left analog stick on a controller (when pushed left).</summary> + LeftThumbstickLeft = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickLeft, + + /// <summary>The left analog stick on a controller (when pushed right).</summary> + LeftThumbstickRight = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickRight, + + /// <summary>The left analog stick on a controller (when pushed down).</summary> + LeftThumbstickDown = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickDown, + + /// <summary>The left analog stick on a controller (when pushed up).</summary> + LeftThumbstickUp = SButtonExtensions.ControllerOffset + Buttons.LeftThumbstickUp, + + /// <summary>The right analog stick on a controller (when pushed left).</summary> + RightThumbstickLeft = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickLeft, + + /// <summary>The right analog stick on a controller (when pushed right).</summary> + RightThumbstickRight = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickRight, + + /// <summary>The right analog stick on a controller (when pushed down).</summary> + RightThumbstickDown = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickDown, + + /// <summary>The right analog stick on a controller (when pushed up).</summary> + RightThumbstickUp = SButtonExtensions.ControllerOffset + Buttons.RightThumbstickUp, + + /********* + ** Keyboard + *********/ + /// <summary>The A button on a keyboard.</summary> + A = Keys.A, + + /// <summary>The Add button on a keyboard.</summary> + Add = Keys.Add, + + /// <summary>The Applications button on a keyboard.</summary> + Apps = Keys.Apps, + + /// <summary>The Attn button on a keyboard.</summary> + Attn = Keys.Attn, + + /// <summary>The B button on a keyboard.</summary> + B = Keys.B, + + /// <summary>The Backspace button on a keyboard.</summary> + Back = Keys.Back, + + /// <summary>The Browser Back button on a keyboard in Windows 2000/XP.</summary> + BrowserBack = Keys.BrowserBack, + + /// <summary>The Browser Favorites button on a keyboard in Windows 2000/XP.</summary> + BrowserFavorites = Keys.BrowserFavorites, + + /// <summary>The Browser Favorites button on a keyboard in Windows 2000/XP.</summary> + BrowserForward = Keys.BrowserForward, + + /// <summary>The Browser Home button on a keyboard in Windows 2000/XP.</summary> + BrowserHome = Keys.BrowserHome, + + /// <summary>The Browser Refresh button on a keyboard in Windows 2000/XP.</summary> + BrowserRefresh = Keys.BrowserRefresh, + + /// <summary>The Browser Search button on a keyboard in Windows 2000/XP.</summary> + BrowserSearch = Keys.BrowserSearch, + + /// <summary>The Browser Stop button on a keyboard in Windows 2000/XP.</summary> + BrowserStop = Keys.BrowserStop, + + /// <summary>The C button on a keyboard.</summary> + C = Keys.C, + + /// <summary>The Caps Lock button on a keyboard.</summary> + CapsLock = Keys.CapsLock, + + /// <summary>The Green ChatPad button on a keyboard.</summary> + ChatPadGreen = Keys.ChatPadGreen, + + /// <summary>The Orange ChatPad button on a keyboard.</summary> + ChatPadOrange = Keys.ChatPadOrange, + + /// <summary>The CrSel button on a keyboard.</summary> + Crsel = Keys.Crsel, + + /// <summary>The D button on a keyboard.</summary> + D = Keys.D, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D0 = Keys.D0, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D1 = Keys.D1, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D2 = Keys.D2, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D3 = Keys.D3, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D4 = Keys.D4, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D5 = Keys.D5, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D6 = Keys.D6, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D7 = Keys.D7, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D8 = Keys.D8, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + D9 = Keys.D9, + + /// <summary>The Decimal button on a keyboard.</summary> + Decimal = Keys.Decimal, + + /// <summary>The Delete button on a keyboard.</summary> + Delete = Keys.Delete, + + /// <summary>The Divide button on a keyboard.</summary> + Divide = Keys.Divide, + + /// <summary>The Down arrow button on a keyboard.</summary> + Down = Keys.Down, + + /// <summary>The E button on a keyboard.</summary> + E = Keys.E, + + /// <summary>The End button on a keyboard.</summary> + End = Keys.End, + + /// <summary>The Enter button on a keyboard.</summary> + Enter = Keys.Enter, + + /// <summary>The Erase EOF button on a keyboard.</summary> + EraseEof = Keys.EraseEof, + + /// <summary>The Escape button on a keyboard.</summary> + Escape = Keys.Escape, + + /// <summary>The Execute button on a keyboard.</summary> + Execute = Keys.Execute, + + /// <summary>The ExSel button on a keyboard.</summary> + Exsel = Keys.Exsel, + + /// <summary>The F button on a keyboard.</summary> + F = Keys.F, + + /// <summary>The F1 button on a keyboard.</summary> + F1 = Keys.F1, + + /// <summary>The F10 button on a keyboard.</summary> + F10 = Keys.F10, + + /// <summary>The F11 button on a keyboard.</summary> + F11 = Keys.F11, + + /// <summary>The F12 button on a keyboard.</summary> + F12 = Keys.F12, + + /// <summary>The F13 button on a keyboard.</summary> + F13 = Keys.F13, + + /// <summary>The F14 button on a keyboard.</summary> + F14 = Keys.F14, + + /// <summary>The F15 button on a keyboard.</summary> + F15 = Keys.F15, + + /// <summary>The F16 button on a keyboard.</summary> + F16 = Keys.F16, + + /// <summary>The F17 button on a keyboard.</summary> + F17 = Keys.F17, + + /// <summary>The F18 button on a keyboard.</summary> + F18 = Keys.F18, + + /// <summary>The F19 button on a keyboard.</summary> + F19 = Keys.F19, + + /// <summary>The F2 button on a keyboard.</summary> + F2 = Keys.F2, + + /// <summary>The F20 button on a keyboard.</summary> + F20 = Keys.F20, + + /// <summary>The F21 button on a keyboard.</summary> + F21 = Keys.F21, + + /// <summary>The F22 button on a keyboard.</summary> + F22 = Keys.F22, + + /// <summary>The F23 button on a keyboard.</summary> + F23 = Keys.F23, + + /// <summary>The F24 button on a keyboard.</summary> + F24 = Keys.F24, + + /// <summary>The F3 button on a keyboard.</summary> + F3 = Keys.F3, + + /// <summary>The F4 button on a keyboard.</summary> + F4 = Keys.F4, + + /// <summary>The F5 button on a keyboard.</summary> + F5 = Keys.F5, + + /// <summary>The F6 button on a keyboard.</summary> + F6 = Keys.F6, + + /// <summary>The F7 button on a keyboard.</summary> + F7 = Keys.F7, + + /// <summary>The F8 button on a keyboard.</summary> + F8 = Keys.F8, + + /// <summary>The F9 button on a keyboard.</summary> + F9 = Keys.F9, + + /// <summary>The G button on a keyboard.</summary> + G = Keys.G, + + /// <summary>The H button on a keyboard.</summary> + H = Keys.H, + + /// <summary>The Help button on a keyboard.</summary> + Help = Keys.Help, + + /// <summary>The Home button on a keyboard.</summary> + Home = Keys.Home, + + /// <summary>The I button on a keyboard.</summary> + I = Keys.I, + + /// <summary>The IME Convert button on a keyboard.</summary> + ImeConvert = Keys.ImeConvert, + + /// <summary>The IME NoConvert button on a keyboard.</summary> + ImeNoConvert = Keys.ImeNoConvert, + + /// <summary>The INS button on a keyboard.</summary> + Insert = Keys.Insert, + + /// <summary>The J button on a keyboard.</summary> + J = Keys.J, + + /// <summary>The K button on a keyboard.</summary> + K = Keys.K, + + /// <summary>The Kana button on a Japanese keyboard.</summary> + Kana = Keys.Kana, + + /// <summary>The Kanji button on a Japanese keyboard.</summary> + Kanji = Keys.Kanji, + + /// <summary>The L button on a keyboard.</summary> + L = Keys.L, + + /// <summary>The Start Applications 1 button on a keyboard in Windows 2000/XP.</summary> + LaunchApplication1 = Keys.LaunchApplication1, + + /// <summary>The Start Applications 2 button on a keyboard in Windows 2000/XP.</summary> + LaunchApplication2 = Keys.LaunchApplication2, + + /// <summary>The Start Mail button on a keyboard in Windows 2000/XP.</summary> + LaunchMail = Keys.LaunchMail, + + /// <summary>The Left arrow button on a keyboard.</summary> + Left = Keys.Left, + + /// <summary>The Left Alt button on a keyboard.</summary> + LeftAlt = Keys.LeftAlt, + + /// <summary>The Left Control button on a keyboard.</summary> + LeftControl = Keys.LeftControl, + + /// <summary>The Left Shift button on a keyboard.</summary> + LeftShift = Keys.LeftShift, + + /// <summary>The Left Windows button on a keyboard.</summary> + LeftWindows = Keys.LeftWindows, + + /// <summary>The M button on a keyboard.</summary> + M = Keys.M, + + /// <summary>The MediaNextTrack button on a keyboard in Windows 2000/XP.</summary> + MediaNextTrack = Keys.MediaNextTrack, + + /// <summary>The MediaPlayPause button on a keyboard in Windows 2000/XP.</summary> + MediaPlayPause = Keys.MediaPlayPause, + + /// <summary>The MediaPreviousTrack button on a keyboard in Windows 2000/XP.</summary> + MediaPreviousTrack = Keys.MediaPreviousTrack, + + /// <summary>The MediaStop button on a keyboard in Windows 2000/XP.</summary> + MediaStop = Keys.MediaStop, + + /// <summary>The Multiply button on a keyboard.</summary> + Multiply = Keys.Multiply, + + /// <summary>The N button on a keyboard.</summary> + N = Keys.N, + + /// <summary>The Num Lock button on a keyboard.</summary> + NumLock = Keys.NumLock, + + /// <summary>The Numeric keypad 0 button on a keyboard.</summary> + NumPad0 = Keys.NumPad0, + + /// <summary>The Numeric keypad 1 button on a keyboard.</summary> + NumPad1 = Keys.NumPad1, + + /// <summary>The Numeric keypad 2 button on a keyboard.</summary> + NumPad2 = Keys.NumPad2, + + /// <summary>The Numeric keypad 3 button on a keyboard.</summary> + NumPad3 = Keys.NumPad3, + + /// <summary>The Numeric keypad 4 button on a keyboard.</summary> + NumPad4 = Keys.NumPad4, + + /// <summary>The Numeric keypad 5 button on a keyboard.</summary> + NumPad5 = Keys.NumPad5, + + /// <summary>The Numeric keypad 6 button on a keyboard.</summary> + NumPad6 = Keys.NumPad6, + + /// <summary>The Numeric keypad 7 button on a keyboard.</summary> + NumPad7 = Keys.NumPad7, + + /// <summary>The Numeric keypad 8 button on a keyboard.</summary> + NumPad8 = Keys.NumPad8, + + /// <summary>The Numeric keypad 9 button on a keyboard.</summary> + NumPad9 = Keys.NumPad9, + + /// <summary>The O button on a keyboard.</summary> + O = Keys.O, + + /// <summary>A miscellaneous button on a keyboard; can vary by keyboard.</summary> + Oem8 = Keys.Oem8, + + /// <summary>The OEM Auto button on a keyboard.</summary> + OemAuto = Keys.OemAuto, + + /// <summary>The OEM Angle Bracket or Backslash button on the RT 102 keyboard in Windows 2000/XP.</summary> + OemBackslash = Keys.OemBackslash, + + /// <summary>The Clear button on a keyboard.</summary> + OemClear = Keys.OemClear, + + /// <summary>The OEM Close Bracket button on a US standard keyboard in Windows 2000/XP.</summary> + OemCloseBrackets = Keys.OemCloseBrackets, + + /// <summary>The ',' button on a keyboard in any country/region in Windows 2000/XP.</summary> + OemComma = Keys.OemComma, + + /// <summary>The OEM Copy button on a keyboard.</summary> + OemCopy = Keys.OemCopy, + + /// <summary>The OEM Enlarge Window button on a keyboard.</summary> + OemEnlW = Keys.OemEnlW, + + /// <summary>The '-' button on a keyboard in any country/region in Windows 2000/XP.</summary> + OemMinus = Keys.OemMinus, + + /// <summary>The OEM Open Bracket button on a US standard keyboard in Windows 2000/XP.</summary> + OemOpenBrackets = Keys.OemOpenBrackets, + + /// <summary>The '.' button on a keyboard in any country/region.</summary> + OemPeriod = Keys.OemPeriod, + + /// <summary>The OEM Pipe button on a US standard keyboard.</summary> + OemPipe = Keys.OemPipe, + + /// <summary>The '+' button on a keyboard in Windows 2000/XP.</summary> + OemPlus = Keys.OemPlus, + + /// <summary>The OEM Question Mark button on a US standard keyboard.</summary> + OemQuestion = Keys.OemQuestion, + + /// <summary>The OEM Single/Double Quote button on a US standard keyboard.</summary> + OemQuotes = Keys.OemQuotes, + + /// <summary>The OEM Semicolon button on a US standard keyboard.</summary> + OemSemicolon = Keys.OemSemicolon, + + /// <summary>The OEM Tilde button on a US standard keyboard.</summary> + OemTilde = Keys.OemTilde, + + /// <summary>The P button on a keyboard.</summary> + P = Keys.P, + + /// <summary>The PA1 button on a keyboard.</summary> + Pa1 = Keys.Pa1, + + /// <summary>The Page Down button on a keyboard.</summary> + PageDown = Keys.PageDown, + + /// <summary>The Page Up button on a keyboard.</summary> + PageUp = Keys.PageUp, + + /// <summary>The Pause button on a keyboard.</summary> + Pause = Keys.Pause, + + /// <summary>The Play button on a keyboard.</summary> + Play = Keys.Play, + + /// <summary>The Print button on a keyboard.</summary> + Print = Keys.Print, + + /// <summary>The Print Screen button on a keyboard.</summary> + PrintScreen = Keys.PrintScreen, + + /// <summary>The IME Process button on a keyboard in Windows 95/98/ME/NT 4.0/2000/XP.</summary> + ProcessKey = Keys.ProcessKey, + + /// <summary>The Q button on a keyboard.</summary> + Q = Keys.Q, + + /// <summary>The R button on a keyboard.</summary> + R = Keys.R, + + /// <summary>The Right Arrow button on a keyboard.</summary> + Right = Keys.Right, + + /// <summary>The Right Alt button on a keyboard.</summary> + RightAlt = Keys.RightAlt, + + /// <summary>The Right Control button on a keyboard.</summary> + RightControl = Keys.RightControl, + + /// <summary>The Right Shift button on a keyboard.</summary> + RightShift = Keys.RightShift, + + /// <summary>The Right Windows button on a keyboard.</summary> + RightWindows = Keys.RightWindows, + + /// <summary>The S button on a keyboard.</summary> + S = Keys.S, + + /// <summary>The Scroll Lock button on a keyboard.</summary> + Scroll = Keys.Scroll, + + /// <summary>The Select button on a keyboard.</summary> + Select = Keys.Select, + + /// <summary>The Select Media button on a keyboard in Windows 2000/XP.</summary> + SelectMedia = Keys.SelectMedia, + + /// <summary>The Separator button on a keyboard.</summary> + Separator = Keys.Separator, + + /// <summary>The Computer Sleep button on a keyboard.</summary> + Sleep = Keys.Sleep, + + /// <summary>The Space bar on a keyboard.</summary> + Space = Keys.Space, + + /// <summary>The Subtract button on a keyboard.</summary> + Subtract = Keys.Subtract, + + /// <summary>The T button on a keyboard.</summary> + T = Keys.T, + + /// <summary>The Tab button on a keyboard.</summary> + Tab = Keys.Tab, + + /// <summary>The U button on a keyboard.</summary> + U = Keys.U, + + /// <summary>The Up Arrow button on a keyboard.</summary> + Up = Keys.Up, + + /// <summary>The V button on a keyboard.</summary> + V = Keys.V, + + /// <summary>The Volume Down button on a keyboard in Windows 2000/XP.</summary> + VolumeDown = Keys.VolumeDown, + + /// <summary>The Volume Mute button on a keyboard in Windows 2000/XP.</summary> + VolumeMute = Keys.VolumeMute, + + /// <summary>The Volume Up button on a keyboard in Windows 2000/XP.</summary> + VolumeUp = Keys.VolumeUp, + + /// <summary>The W button on a keyboard.</summary> + W = Keys.W, + + /// <summary>The X button on a keyboard.</summary> + X = Keys.X, + + /// <summary>The Y button on a keyboard.</summary> + Y = Keys.Y, + + /// <summary>The Z button on a keyboard.</summary> + Z = Keys.Z, + + /// <summary>The Zoom button on a keyboard.</summary> + Zoom = Keys.Zoom + } + + /// <summary>Provides extension methods for <see cref="SButton"/>.</summary> +#if SMAPI_1_x + internal +#else + public +#endif + static class SButtonExtensions + { + /********* + ** Accessors + *********/ + /// <summary>The offset added to <see cref="Buttons"/> values when converting them to <see cref="SButton"/> to avoid collisions with <see cref="Keys"/> values.</summary> + internal const int ControllerOffset = 2000; + + + /********* + ** Public methods + *********/ + /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> + /// <param name="key">The keyboard button to convert.</param> + internal static SButton ToSButton(this Keys key) + { + return (SButton)key; + } + + /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> + /// <param name="key">The controller button to convert.</param> + internal static SButton ToSButton(this Buttons key) + { + return (SButton)(SButtonExtensions.ControllerOffset + key); + } + + /// <summary>Get the <see cref="Keys"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="key">The keyboard equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetKeyboard(this SButton input, out Keys key) + { + if (Enum.IsDefined(typeof(Keys), (int)input)) + { + key = (Keys)input; + return true; + } + + key = Keys.None; + return false; + } + + /// <summary>Get the <see cref="Buttons"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="button">The controller equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetController(this SButton input, out Buttons button) + { + if (Enum.IsDefined(typeof(Buttons), (int)input - SButtonExtensions.ControllerOffset)) + { + button = (Buttons)(input - SButtonExtensions.ControllerOffset); + return true; + } + + button = 0; + return false; + } + + /// <summary>Get the <see cref="InputButton"/> equivalent for the given button.</summary> + /// <param name="input">The button to convert.</param> + /// <param name="button">The Stardew Valley input button equivalent.</param> + /// <returns>Returns whether the value was converted successfully.</returns> + public static bool TryGetStardewInput(this SButton input, out InputButton button) + { + // keyboard + if (input.TryGetKeyboard(out Keys key)) + { + button = new InputButton(key); + return true; + } + + // mouse + if (input == SButton.MouseLeft || input == SButton.MouseRight) + { + button = new InputButton(mouseLeft: input == SButton.MouseLeft); + return true; + } + + // not valid + button = default(InputButton); + return false; + } + } +} diff --git a/src/StardewModdingAPI/Utilities/SDate.cs b/src/StardewModdingAPI/Utilities/SDate.cs new file mode 100644 index 00000000..5073259d --- /dev/null +++ b/src/StardewModdingAPI/Utilities/SDate.cs @@ -0,0 +1,236 @@ +using System; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Utilities +{ + /// <summary>Represents a Stardew Valley date.</summary> + public class SDate : IEquatable<SDate> + { + /********* + ** Properties + *********/ + /// <summary>The internal season names in order.</summary> + private readonly string[] Seasons = { "spring", "summer", "fall", "winter" }; + + /// <summary>The number of seasons in a year.</summary> + private int SeasonsInYear => this.Seasons.Length; + + /// <summary>The number of days in a season.</summary> + private readonly int DaysInSeason = 28; + + + /********* + ** Accessors + *********/ + /// <summary>The day of month.</summary> + public int Day { get; } + + /// <summary>The season name.</summary> + public string Season { get; } + + /// <summary>The year.</summary> + public int Year { get; } + +#if !SMAPI_1_x + /// <summary>The day of week.</summary> + public DayOfWeek DayOfWeek { get; } +#endif + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="day">The day of month.</param> + /// <param name="season">The season name.</param> + /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + public SDate(int day, string season) + : this(day, season, Game1.year) { } + + /// <summary>Construct an instance.</summary> + /// <param name="day">The day of month.</param> + /// <param name="season">The season name.</param> + /// <param name="year">The year.</param> + /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + public SDate(int day, string season, int year) + { + // validate + if (season == null) + throw new ArgumentNullException(nameof(season)); + if (!this.Seasons.Contains(season)) + throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", this.Seasons)}]."); + if (day < 1 || day > this.DaysInSeason) + throw new ArgumentException($"Invalid day '{day}', must be a value from 1 to {this.DaysInSeason}."); + if (year < 1) + throw new ArgumentException($"Invalid year '{year}', must be at least 1."); + + // initialise + this.Day = day; + this.Season = season; + this.Year = year; +#if !SMAPI_1_x + this.DayOfWeek = this.GetDayOfWeek(); +#endif + } + + /// <summary>Get the current in-game date.</summary> + public static SDate Now() + { + return new SDate(Game1.dayOfMonth, Game1.currentSeason, Game1.year); + } + + /// <summary>Get a new date with the given number of days added.</summary> + /// <param name="offset">The number of days to add.</param> + /// <returns>Returns the resulting date.</returns> + /// <exception cref="ArithmeticException">The offset would result in an invalid date (like year 0).</exception> + public SDate AddDays(int offset) + { + // get new hash code + int hashCode = this.GetHashCode() + offset; + if (hashCode < 1) + throw new ArithmeticException($"Adding {offset} days to {this} would result in a date before 01 spring Y1."); + + // get day + int day = hashCode % 28; + if (day == 0) + day = 28; + + // get season index + int seasonIndex = hashCode / 28; + if (seasonIndex > 0 && hashCode % 28 == 0) + seasonIndex -= 1; + seasonIndex %= 4; + + // get year + int year = hashCode / (this.Seasons.Length * this.DaysInSeason) + 1; + + // create date + return new SDate(day, this.Seasons[seasonIndex], year); + } + + /// <summary>Get a string representation of the date. This is mainly intended for debugging or console messages.</summary> + public override string ToString() + { + return $"{this.Day:00} {this.Season} Y{this.Year}"; + } + + /**** + ** IEquatable + ****/ + /// <summary>Get whether this instance is equal to another.</summary> + /// <param name="other">The other value to compare.</param> + public bool Equals(SDate other) + { + return this == other; + } + + /// <summary>Get whether this instance is equal to another.</summary> + /// <param name="obj">The other value to compare.</param> + public override bool Equals(object obj) + { + return obj is SDate other && this == other; + } + + /// <summary>Get a hash code which uniquely identifies a date.</summary> + public override int GetHashCode() + { + // return the number of days since 01 spring Y1 (inclusively) + int yearIndex = this.Year - 1; + return + yearIndex * this.DaysInSeason * this.SeasonsInYear + + this.GetSeasonIndex() * this.DaysInSeason + + this.Day; + } + + /**** + ** Operators + ****/ + /// <summary>Get whether one date is equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + /// <returns>The equality of the dates</returns> + public static bool operator ==(SDate date, SDate other) + { + return date?.GetHashCode() == other?.GetHashCode(); + } + + /// <summary>Get whether one date is not equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator !=(SDate date, SDate other) + { + return date?.GetHashCode() != other?.GetHashCode(); + } + + /// <summary>Get whether one date is more than another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator >(SDate date, SDate other) + { + return date?.GetHashCode() > other?.GetHashCode(); + } + + /// <summary>Get whether one date is more than or equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator >=(SDate date, SDate other) + { + return date?.GetHashCode() >= other?.GetHashCode(); + } + + /// <summary>Get whether one date is less than or equal to another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator <=(SDate date, SDate other) + { + return date?.GetHashCode() <= other?.GetHashCode(); + } + + /// <summary>Get whether one date is less than another.</summary> + /// <param name="date">The base date to compare.</param> + /// <param name="other">The other date to compare.</param> + public static bool operator <(SDate date, SDate other) + { + return date?.GetHashCode() < other?.GetHashCode(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the day of week for the current date.</summary> + private DayOfWeek GetDayOfWeek() + { + switch (this.Day % 7) + { + case 0: + return DayOfWeek.Sunday; + case 1: + return DayOfWeek.Monday; + case 2: + return DayOfWeek.Tuesday; + case 3: + return DayOfWeek.Wednesday; + case 4: + return DayOfWeek.Thursday; + case 5: + return DayOfWeek.Friday; + case 6: + return DayOfWeek.Saturday; + default: + return 0; + } + } + + /// <summary>Get the current season index.</summary> + /// <exception cref="InvalidOperationException">The current season wasn't recognised.</exception> + private int GetSeasonIndex() + { + int index = Array.IndexOf(this.Seasons, this.Season); + if (index == -1) + throw new InvalidOperationException($"The current season '{this.Season}' wasn't recognised."); + return index; + } + } +} diff --git a/src/StardewModdingAPI/icon.ico b/src/StardewModdingAPI/icon.ico Binary files differnew file mode 100644 index 00000000..587a6e74 --- /dev/null +++ b/src/StardewModdingAPI/icon.ico diff --git a/src/StardewModdingAPI/packages.config b/src/StardewModdingAPI/packages.config new file mode 100644 index 00000000..e5fa3c3a --- /dev/null +++ b/src/StardewModdingAPI/packages.config @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net45" /> + <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net461" /> +</packages>
\ No newline at end of file diff --git a/src/StardewModdingAPI/steam_appid.txt b/src/StardewModdingAPI/steam_appid.txt new file mode 100644 index 00000000..9fe92b96 --- /dev/null +++ b/src/StardewModdingAPI/steam_appid.txt @@ -0,0 +1 @@ +413150
\ No newline at end of file diff --git a/src/StardewModdingAPI/unix-launcher.sh b/src/StardewModdingAPI/unix-launcher.sh new file mode 100644 index 00000000..70f1873a --- /dev/null +++ b/src/StardewModdingAPI/unix-launcher.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# MonoKickstart Shell Script +# Written by Ethan "flibitijibibo" Lee +# Modified for StardewModdingAPI by Viz and Pathoschild + +# Move to script's directory +cd "`dirname "$0"`" + +# Get the system architecture +UNAME=`uname` +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/ + + # 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 + + # 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 + + # 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 xfce4-terminal 2>/dev/null; then + xfce4-terminal -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 +fi diff --git a/src/TrainerMod/Framework/Commands/ArgumentParser.cs b/src/TrainerMod/Framework/Commands/ArgumentParser.cs new file mode 100644 index 00000000..6bcd3ff8 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/ArgumentParser.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; + +namespace TrainerMod.Framework.Commands +{ + /// <summary>Provides methods for parsing command-line arguments.</summary> + internal class ArgumentParser : IReadOnlyList<string> + { + /********* + ** Properties + *********/ + /// <summary>The command name for errors.</summary> + private readonly string CommandName; + + /// <summary>The arguments to parse.</summary> + private readonly string[] Args; + + /// <summary>Writes messages to the console and log file.</summary> + private readonly IMonitor Monitor; + + + /********* + ** Accessors + *********/ + /// <summary>Get the number of arguments.</summary> + public int Count => this.Args.Length; + + /// <summary>Get the argument at the specified index in the list.</summary> + /// <param name="index">The zero-based index of the element to get.</param> + public string this[int index] => this.Args[index]; + + /// <summary>A method which parses a string argument into the given value.</summary> + /// <typeparam name="T">The expected argument type.</typeparam> + /// <param name="input">The argument to parse.</param> + /// <param name="output">The parsed value.</param> + /// <returns>Returns whether the argument was successfully parsed.</returns> + public delegate bool ParseDelegate<T>(string input, out T output); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="commandName">The command name for errors.</param> + /// <param name="args">The arguments to parse.</param> + /// <param name="monitor">Writes messages to the console and log file.</param> + public ArgumentParser(string commandName, string[] args, IMonitor monitor) + { + this.CommandName = commandName; + this.Args = args; + this.Monitor = monitor; + } + + /// <summary>Try to read a string argument.</summary> + /// <param name="index">The argument index.</param> + /// <param name="name">The argument name for error messages.</param> + /// <param name="value">The parsed value.</param> + /// <param name="required">Whether to show an error if the argument is missing.</param> + /// <param name="oneOf">Require that the argument match one of the given values (case-insensitive).</param> + public bool TryGet(int index, string name, out string value, bool required = true, string[] oneOf = null) + { + value = null; + + // validate + if (this.Args.Length < index + 1) + { + if (required) + this.LogError($"Argument {index} ({name}) is required."); + return false; + } + if (oneOf?.Any() == true && !oneOf.Contains(this.Args[index], StringComparer.InvariantCultureIgnoreCase)) + { + this.LogError($"Argument {index} ({name}) must be one of {string.Join(", ", oneOf)}."); + return false; + } + + // get value + value = this.Args[index]; + return true; + } + + /// <summary>Try to read an integer argument.</summary> + /// <param name="index">The argument index.</param> + /// <param name="name">The argument name for error messages.</param> + /// <param name="value">The parsed value.</param> + /// <param name="required">Whether to show an error if the argument is missing.</param> + /// <param name="min">The minimum value allowed.</param> + /// <param name="max">The maximum value allowed.</param> + public bool TryGetInt(int index, string name, out int value, bool required = true, int? min = null, int? max = null) + { + value = 0; + + // get argument + if (!this.TryGet(index, name, out string raw, required)) + return false; + + // parse + if (!int.TryParse(raw, out value)) + { + this.LogIntFormatError(index, name, min, max); + return false; + } + + // validate + if ((min.HasValue && value < min) || (max.HasValue && value > max)) + { + this.LogIntFormatError(index, name, min, max); + return false; + } + + return true; + } + + /// <summary>Returns an enumerator that iterates through the collection.</summary> + /// <returns>An enumerator that can be used to iterate through the collection.</returns> + public IEnumerator<string> GetEnumerator() + { + return ((IEnumerable<string>)this.Args).GetEnumerator(); + } + + /// <summary>Returns an enumerator that iterates through a collection.</summary> + /// <returns>An <see cref="T:System.Collections.IEnumerator" /> object that can be used to iterate through the collection.</returns> + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Log a usage error.</summary> + /// <param name="message">The message describing the error.</param> + private void LogError(string message) + { + this.Monitor.Log($"{message} Type 'help {this.CommandName}' for usage.", LogLevel.Error); + } + + /// <summary>Print an error for an invalid int argument.</summary> + /// <param name="index">The argument index.</param> + /// <param name="name">The argument name for error messages.</param> + /// <param name="min">The minimum value allowed.</param> + /// <param name="max">The maximum value allowed.</param> + private void LogIntFormatError(int index, string name, int? min, int? max) + { + if (min.HasValue && max.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer between {min} and {max}."); + else if (min.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer and at least {min}."); + else if (max.HasValue) + this.LogError($"Argument {index} ({name}) must be an integer and at most {max}."); + else + this.LogError($"Argument {index} ({name}) must be an integer."); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/ITrainerCommand.cs b/src/TrainerMod/Framework/Commands/ITrainerCommand.cs new file mode 100644 index 00000000..3d97e799 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/ITrainerCommand.cs @@ -0,0 +1,34 @@ +using StardewModdingAPI; + +namespace TrainerMod.Framework.Commands +{ + /// <summary>A TrainerMod command to register.</summary> + internal interface ITrainerCommand + { + /********* + ** Accessors + *********/ + /// <summary>The command name the user must type.</summary> + string Name { get; } + + /// <summary>The command description.</summary> + string Description { get; } + + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + bool NeedsUpdate { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + void Handle(IMonitor monitor, string command, ArgumentParser args); + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + void Update(IMonitor monitor); + } +} diff --git a/src/TrainerMod/Framework/Commands/Other/DebugCommand.cs b/src/TrainerMod/Framework/Commands/Other/DebugCommand.cs new file mode 100644 index 00000000..8c6e9f3b --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Other/DebugCommand.cs @@ -0,0 +1,33 @@ +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Other +{ + /// <summary>A command which sends a debug command to the game.</summary> + internal class DebugCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public DebugCommand() + : base("debug", "Run one of the game's debug commands; for example, 'debug warp FarmHouse 1 1' warps the player to the farmhouse.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // submit command + string debugCommand = string.Join(" ", args); + string oldOutput = Game1.debugOutput; + Game1.game1.parseDebugInput(debugCommand); + + // show result + monitor.Log(Game1.debugOutput != oldOutput + ? $"> {Game1.debugOutput}" + : "Sent debug command to the game, but there was no output.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs b/src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs new file mode 100644 index 00000000..367a70c6 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Other/ShowDataFilesCommand.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using StardewModdingAPI; + +namespace TrainerMod.Framework.Commands.Other +{ + /// <summary>A command which shows the data files.</summary> + internal class ShowDataFilesCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ShowDataFilesCommand() + : base("show_data_files", "Opens the folder containing the save and log files.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Process.Start(Constants.DataPath); + monitor.Log($"OK, opening {Constants.DataPath}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs new file mode 100644 index 00000000..67fa83a3 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Other/ShowGameFilesCommand.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using StardewModdingAPI; + +namespace TrainerMod.Framework.Commands.Other +{ + /// <summary>A command which shows the game files.</summary> + internal class ShowGameFilesCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ShowGameFilesCommand() + : base("show_game_files", "Opens the game folder.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + Process.Start(Constants.ExecutionPath); + monitor.Log($"OK, opening {Constants.ExecutionPath}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/AddCommand.cs b/src/TrainerMod/Framework/Commands/Player/AddCommand.cs new file mode 100644 index 00000000..47840202 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/AddCommand.cs @@ -0,0 +1,81 @@ +using System; +using System.Linq; +using StardewModdingAPI; +using StardewValley; +using TrainerMod.Framework.ItemData; +using Object = StardewValley.Object; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which adds an item to the player inventory.</summary> + internal class AddCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Provides methods for searching and constructing items.</summary> + private readonly ItemRepository Items = new ItemRepository(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public AddCommand() + : base("player_add", AddCommand.GetDescription()) + { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // read arguments + if (!args.TryGet(0, "item type", out string rawType, oneOf: Enum.GetNames(typeof(ItemType)))) + return; + if (!args.TryGetInt(1, "item ID", out int id, min: 0)) + return; + if (!args.TryGetInt(2, "count", out int count, min: 1, required: false)) + count = 1; + if (!args.TryGetInt(3, "quality", out int quality, min: Object.lowQuality, max: Object.bestQuality, required: false)) + quality = Object.lowQuality; + ItemType type = (ItemType)Enum.Parse(typeof(ItemType), rawType, ignoreCase: true); + + // find matching item + SearchableItem match = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id); + if (match == null) + { + monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error); + return; + } + + // apply count & quality + match.Item.Stack = count; + if (match.Item is Object obj) + obj.quality = quality; + + // add to inventory + Game1.player.addItemByMenuIfNecessary(match.Item); + monitor.Log($"OK, added {match.Name} ({match.Type} #{match.ID}) to your inventory.", LogLevel.Info); + } + + /********* + ** Private methods + *********/ + private static string GetDescription() + { + string[] typeValues = Enum.GetNames(typeof(ItemType)); + return "Gives the player an item.\n" + + "\n" + + "Usage: player_add <type> <item> [count] [quality]\n" + + $"- type: the item type (one of {string.Join(", ", typeValues)}).\n" + + "- item: the item ID (use the 'list_items' command to see a list).\n" + + "- count (optional): how many of the item to give.\n" + + $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n" + + "\n" + + "This example adds the galaxy sword to your inventory:\n" + + " player_add weapon 4"; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs b/src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs new file mode 100644 index 00000000..5f14edbb --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/ListItemTypesCommand.cs @@ -0,0 +1,53 @@ +using System.Linq; +using StardewModdingAPI; +using TrainerMod.Framework.ItemData; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which list item types.</summary> + internal class ListItemTypesCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Provides methods for searching and constructing items.</summary> + private readonly ItemRepository Items = new ItemRepository(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ListItemTypesCommand() + : base("list_item_types", "Lists item types you can filter in other commands.\n\nUsage: list_item_types") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!Context.IsWorldReady) + { + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } + + // handle + ItemType[] matches = + ( + from item in this.Items.GetAll() + orderby item.Type.ToString() + select item.Type + ) + .Distinct() + .ToArray(); + string summary = "Searching...\n"; + if (matches.Any()) + monitor.Log(summary + this.GetTableString(matches, new[] { "type" }, val => new[] { val.ToString() }), LogLevel.Info); + else + monitor.Log(summary + "No item types found.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs b/src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs new file mode 100644 index 00000000..7f4f454c --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/ListItemsCommand.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; +using TrainerMod.Framework.ItemData; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which list items available to spawn.</summary> + internal class ListItemsCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Provides methods for searching and constructing items.</summary> + private readonly ItemRepository Items = new ItemRepository(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ListItemsCommand() + : base("list_items", "Lists and searches items in the game data.\n\nUsage: list_items [search]\n- search (optional): an arbitrary search string to filter by.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!Context.IsWorldReady) + { + monitor.Log("You need to load a save to use this command.", LogLevel.Error); + return; + } + + // handle + SearchableItem[] matches = + ( + from item in this.GetItems(args.ToArray()) + orderby item.Type.ToString(), item.Name + select item + ) + .ToArray(); + string summary = "Searching...\n"; + if (matches.Any()) + monitor.Log(summary + this.GetTableString(matches, new[] { "type", "name", "id" }, val => new[] { val.Type.ToString(), val.Name, val.ID.ToString() }), LogLevel.Info); + else + monitor.Log(summary + "No items found", LogLevel.Info); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get all items which can be searched and added to the player's inventory through the console.</summary> + /// <param name="searchWords">The search string to find.</param> + private IEnumerable<SearchableItem> GetItems(string[] searchWords) + { + // normalise search term + searchWords = searchWords?.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); + if (searchWords?.Any() == false) + searchWords = null; + + // find matches + return ( + from item in this.Items.GetAll() + let term = $"{item.ID}|{item.Type}|{item.Name}|{item.DisplayName}" + where searchWords == null || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) + select item + ); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs new file mode 100644 index 00000000..28ace0df --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetColorCommand.cs @@ -0,0 +1,76 @@ +using Microsoft.Xna.Framework; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the color of a player feature.</summary> + internal class SetColorCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetColorCommand() + : base("player_changecolor", "Sets the color of a player feature.\n\nUsage: player_changecolor <target> <color>\n- target: what to change (one of 'hair', 'eyes', or 'pants').\n- color: a color value in RGB format, like (255,255,255).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "eyes", "pants" })) + return; + if (!args.TryGet(1, "color", out string rawColor)) + return; + + // parse color + if (!this.TryParseColor(rawColor, out Color color)) + { + this.LogUsageError(monitor, "Argument 1 (color) must be an RBG value like '255,150,0'."); + return; + } + + // handle + switch (target) + { + case "hair": + Game1.player.hairstyleColor = color; + monitor.Log("OK, your hair color is updated.", LogLevel.Info); + break; + + case "eyes": + Game1.player.changeEyeColor(color); + monitor.Log("OK, your eye color is updated.", LogLevel.Info); + break; + + case "pants": + Game1.player.pantsColor = color; + monitor.Log("OK, your pants color is updated.", LogLevel.Info); + break; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Try to parse a color from a string.</summary> + /// <param name="input">The input string.</param> + /// <param name="color">The color to set.</param> + private bool TryParseColor(string input, out Color color) + { + string[] colorHexes = input.Split(new[] { ',' }, 3); + if (int.TryParse(colorHexes[0], out int r) && int.TryParse(colorHexes[1], out int g) && int.TryParse(colorHexes[2], out int b)) + { + color = new Color(r, g, b); + return true; + } + + color = Color.Transparent; + return false; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs new file mode 100644 index 00000000..f64e9035 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetHealthCommand.cs @@ -0,0 +1,72 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current health.</summary> + internal class SetHealthCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Whether to keep the player's health at its maximum.</summary> + private bool InfiniteHealth; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + public override bool NeedsUpdate => this.InfiniteHealth; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetHealthCommand() + : base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) + { + monitor.Log($"You currently have {(this.InfiniteHealth ? "infinite" : Game1.player.health.ToString())} health. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + string amountStr = args[0]; + if (amountStr == "inf") + { + this.InfiniteHealth = true; + monitor.Log("OK, you now have infinite health.", LogLevel.Info); + } + else + { + this.InfiniteHealth = false; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.health = amount; + monitor.Log($"OK, you now have {Game1.player.health} health.", LogLevel.Info); + } + else + this.LogArgumentNotInt(monitor); + } + } + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public override void Update(IMonitor monitor) + { + if (this.InfiniteHealth) + Game1.player.health = Game1.player.maxHealth; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs new file mode 100644 index 00000000..59b28a3c --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetImmunityCommand.cs @@ -0,0 +1,38 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current immunity.</summary> + internal class SetImmunityCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetImmunityCommand() + : base("player_setimmunity", "Sets the player's immunity.\n\nUsage: player_setimmunity [value]\n- value: an integer amount.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) + { + monitor.Log($"You currently have {Game1.player.immunity} immunity. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + if (args.TryGetInt(0, "amount", out int amount, min: 0)) + { + Game1.player.immunity = amount; + monitor.Log($"OK, you now have {Game1.player.immunity} immunity.", LogLevel.Info); + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs new file mode 100644 index 00000000..b223aa9f --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetLevelCommand.cs @@ -0,0 +1,63 @@ +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current level for a skill.</summary> + internal class SetLevelCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetLevelCommand() + : base("player_setlevel", "Sets the player's specified skill to the specified value.\n\nUsage: player_setlevel <skill> <value>\n- skill: the skill to set (one of 'luck', 'mining', 'combat', 'farming', 'fishing', or 'foraging').\n- value: the target level (a number from 1 to 10).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.TryGet(0, "skill", out string skill, oneOf: new[] { "luck", "mining", "combat", "farming", "fishing", "foraging" })) + return; + if (!args.TryGetInt(1, "level", out int level, min: 0, max: 10)) + return; + + // handle + switch (skill) + { + case "luck": + Game1.player.LuckLevel = level; + monitor.Log($"OK, your luck skill is now {Game1.player.LuckLevel}.", LogLevel.Info); + break; + + case "mining": + Game1.player.MiningLevel = level; + monitor.Log($"OK, your mining skill is now {Game1.player.MiningLevel}.", LogLevel.Info); + break; + + case "combat": + Game1.player.CombatLevel = level; + monitor.Log($"OK, your combat skill is now {Game1.player.CombatLevel}.", LogLevel.Info); + break; + + case "farming": + Game1.player.FarmingLevel = level; + monitor.Log($"OK, your farming skill is now {Game1.player.FarmingLevel}.", LogLevel.Info); + break; + + case "fishing": + Game1.player.FishingLevel = level; + monitor.Log($"OK, your fishing skill is now {Game1.player.FishingLevel}.", LogLevel.Info); + break; + + case "foraging": + Game1.player.ForagingLevel = level; + monitor.Log($"OK, your foraging skill is now {Game1.player.ForagingLevel}.", LogLevel.Info); + break; + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs new file mode 100644 index 00000000..4b9d87dc --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -0,0 +1,38 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's maximum health.</summary> + internal class SetMaxHealthCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetMaxHealthCommand() + : base("player_setmaxhealth", "Sets the player's max health.\n\nUsage: player_setmaxhealth [value]\n- value: an integer amount.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) + { + monitor.Log($"You currently have {Game1.player.maxHealth} max health. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + if (args.TryGetInt(0, "amount", out int amount, min: 1)) + { + Game1.player.maxHealth = amount; + monitor.Log($"OK, you now have {Game1.player.maxHealth} max health.", LogLevel.Info); + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs new file mode 100644 index 00000000..3997bb1b --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -0,0 +1,38 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's maximum stamina.</summary> + internal class SetMaxStaminaCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetMaxStaminaCommand() + : base("player_setmaxstamina", "Sets the player's max stamina.\n\nUsage: player_setmaxstamina [value]\n- value: an integer amount.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) + { + monitor.Log($"You currently have {Game1.player.MaxStamina} max stamina. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + if (args.TryGetInt(0, "amount", out int amount, min: 1)) + { + Game1.player.MaxStamina = amount; + monitor.Log($"OK, you now have {Game1.player.MaxStamina} max stamina.", LogLevel.Info); + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs new file mode 100644 index 00000000..55e069a4 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetMoneyCommand.cs @@ -0,0 +1,72 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current money.</summary> + internal class SetMoneyCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Whether to keep the player's money at a set value.</summary> + private bool InfiniteMoney; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + public override bool NeedsUpdate => this.InfiniteMoney; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetMoneyCommand() + : base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) + { + monitor.Log($"You currently have {(this.InfiniteMoney ? "infinite" : Game1.player.Money.ToString())} gold. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + string amountStr = args[0]; + if (amountStr == "inf") + { + this.InfiniteMoney = true; + monitor.Log("OK, you now have infinite money.", LogLevel.Info); + } + else + { + this.InfiniteMoney = false; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.Money = amount; + monitor.Log($"OK, you now have {Game1.player.Money} gold.", LogLevel.Info); + } + else + this.LogArgumentNotInt(monitor); + } + } + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public override void Update(IMonitor monitor) + { + if (this.InfiniteMoney) + Game1.player.money = 999999; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs new file mode 100644 index 00000000..3fd4475c --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetNameCommand.cs @@ -0,0 +1,52 @@ +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's name.</summary> + internal class SetNameCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetNameCommand() + : base("player_setname", "Sets the player's name.\n\nUsage: player_setname <target> <name>\n- target: what to rename (one of 'player' or 'farm').\n- name: the new name to set.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGet(0, "target", out string target, oneOf: new[] { "player", "farm" })) + return; + args.TryGet(1, "name", out string name, required: false); + + // handle + switch (target) + { + case "player": + if (!string.IsNullOrWhiteSpace(name)) + { + Game1.player.Name = args[1]; + monitor.Log($"OK, your name is now {Game1.player.Name}.", LogLevel.Info); + } + else + monitor.Log($"Your name is currently '{Game1.player.Name}'. Type 'help player_setname' for usage.", LogLevel.Info); + break; + + case "farm": + if (!string.IsNullOrWhiteSpace(name)) + { + Game1.player.farmName = args[1]; + monitor.Log($"OK, your farm's name is now {Game1.player.farmName}.", LogLevel.Info); + } + else + monitor.Log($"Your farm's name is currently '{Game1.player.farmName}'. Type 'help player_setname' for usage.", LogLevel.Info); + break; + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs new file mode 100644 index 00000000..40b87b62 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetSpeedCommand.cs @@ -0,0 +1,31 @@ +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current added speed.</summary> + internal class SetSpeedCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetSpeedCommand() + : base("player_setspeed", "Sets the player's added speed to the specified value.\n\nUsage: player_setspeed <value>\n- value: an integer amount (0 is normal).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGetInt(0, "added speed", out int amount, min: 0)) + return; + + // handle + Game1.player.addedSpeed = amount; + monitor.Log($"OK, your added speed is now {Game1.player.addedSpeed}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs new file mode 100644 index 00000000..d44d1370 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetStaminaCommand.cs @@ -0,0 +1,72 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits the player's current stamina.</summary> + internal class SetStaminaCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>Whether to keep the player's stamina at its maximum.</summary> + private bool InfiniteStamina; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + public override bool NeedsUpdate => this.InfiniteStamina; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetStaminaCommand() + : base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // validate + if (!args.Any()) + { + monitor.Log($"You currently have {(this.InfiniteStamina ? "infinite" : Game1.player.Stamina.ToString())} stamina. Specify a value to change it.", LogLevel.Info); + return; + } + + // handle + string amountStr = args[0]; + if (amountStr == "inf") + { + this.InfiniteStamina = true; + monitor.Log("OK, you now have infinite stamina.", LogLevel.Info); + } + else + { + this.InfiniteStamina = false; + if (int.TryParse(amountStr, out int amount)) + { + Game1.player.Stamina = amount; + monitor.Log($"OK, you now have {Game1.player.Stamina} stamina.", LogLevel.Info); + } + else + this.LogArgumentNotInt(monitor); + } + } + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public override void Update(IMonitor monitor) + { + if (this.InfiniteStamina) + Game1.player.stamina = Game1.player.MaxStamina; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs b/src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs new file mode 100644 index 00000000..96e34af2 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Player/SetStyleCommand.cs @@ -0,0 +1,92 @@ +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Player +{ + /// <summary>A command which edits a player style.</summary> + internal class SetStyleCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetStyleCommand() + : base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changecolor <target> <value>.\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) + return; + if (!args.TryGetInt(1, "style ID", out int styleID)) + return; + + // handle + switch (target) + { + case "hair": + Game1.player.changeHairStyle(styleID); + monitor.Log("OK, your hair style is updated.", LogLevel.Info); + break; + + case "shirt": + Game1.player.changeShirt(styleID); + monitor.Log("OK, your shirt style is updated.", LogLevel.Info); + break; + + case "acc": + Game1.player.changeAccessory(styleID); + monitor.Log("OK, your accessory style is updated.", LogLevel.Info); + break; + + case "skin": + Game1.player.changeSkinColor(styleID); + monitor.Log("OK, your skin color is updated.", LogLevel.Info); + break; + + case "shoe": + Game1.player.changeShoeColor(styleID); + monitor.Log("OK, your shoe style is updated.", LogLevel.Info); + break; + + case "swim": + switch (styleID) + { + case 0: + Game1.player.changeOutOfSwimSuit(); + monitor.Log("OK, you're no longer in your swimming suit.", LogLevel.Info); + break; + case 1: + Game1.player.changeIntoSwimsuit(); + monitor.Log("OK, you're now in your swimming suit.", LogLevel.Info); + break; + default: + this.LogUsageError(monitor, "The swim value should be 0 (no swimming suit) or 1 (swimming suit)."); + break; + } + break; + + case "gender": + switch (styleID) + { + case 0: + Game1.player.changeGender(true); + monitor.Log("OK, you're now male.", LogLevel.Info); + break; + case 1: + Game1.player.changeGender(false); + monitor.Log("OK, you're now female.", LogLevel.Info); + break; + default: + this.LogUsageError(monitor, "The gender value should be 0 (male) or 1 (female)."); + break; + } + break; + } + } + } +} diff --git a/src/TrainerMod/Framework/Commands/Saves/LoadCommand.cs b/src/TrainerMod/Framework/Commands/Saves/LoadCommand.cs new file mode 100644 index 00000000..ad79b4af --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Saves/LoadCommand.cs @@ -0,0 +1,30 @@ +#if SMAPI_1_x +using StardewModdingAPI; +using StardewValley; +using StardewValley.Menus; + +namespace TrainerMod.Framework.Commands.Saves +{ + /// <summary>A command which shows the load screen.</summary> + internal class LoadCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public LoadCommand() + : base("load", "Shows the load screen.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + monitor.Log("Triggering load menu...", LogLevel.Info); + Game1.hasLoadedGame = false; + Game1.activeClickableMenu = new LoadGameMenu(); + } + } +} +#endif
\ No newline at end of file diff --git a/src/TrainerMod/Framework/Commands/Saves/SaveCommand.cs b/src/TrainerMod/Framework/Commands/Saves/SaveCommand.cs new file mode 100644 index 00000000..ea2bd6a8 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/Saves/SaveCommand.cs @@ -0,0 +1,29 @@ +#if SMAPI_1_x +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.Saves +{ + /// <summary>A command which saves the game.</summary> + internal class SaveCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SaveCommand() + : base("save", "Saves the game? Doesn't seem to work.") { } + + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + monitor.Log("Saving the game...", LogLevel.Info); + SaveGame.Save(); + } + } +} +#endif
\ No newline at end of file diff --git a/src/TrainerMod/Framework/Commands/TrainerCommand.cs b/src/TrainerMod/Framework/Commands/TrainerCommand.cs new file mode 100644 index 00000000..abe9ee41 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/TrainerCommand.cs @@ -0,0 +1,103 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; + +namespace TrainerMod.Framework.Commands +{ + /// <summary>The base implementation for a trainer command.</summary> + internal abstract class TrainerCommand : ITrainerCommand + { + /********* + ** Accessors + *********/ + /// <summary>The command name the user must type.</summary> + public string Name { get; } + + /// <summary>The command description.</summary> + public string Description { get; } + + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + public virtual bool NeedsUpdate { get; } = false; + + + /********* + ** Public methods + *********/ + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public abstract void Handle(IMonitor monitor, string command, ArgumentParser args); + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public virtual void Update(IMonitor monitor) { } + + + /********* + ** Protected methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The command name the user must type.</param> + /// <param name="description">The command description.</param> + protected TrainerCommand(string name, string description) + { + this.Name = name; + this.Description = description; + } + + /// <summary>Log an error indicating incorrect usage.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="error">A sentence explaining the problem.</param> + protected void LogUsageError(IMonitor monitor, string error) + { + monitor.Log($"{error} Type 'help {this.Name}' for usage.", LogLevel.Error); + } + + /// <summary>Log an error indicating a value must be an integer.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + protected void LogArgumentNotInt(IMonitor monitor) + { + this.LogUsageError(monitor, "The value must be a whole number."); + } + + /// <summary>Get an ASCII table to show tabular data in the console.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The data to display.</param> + /// <param name="header">The table header.</param> + /// <param name="getRow">Returns a set of fields for a data value.</param> + protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow) + { + // get table data + int[] widths = header.Select(p => p.Length).ToArray(); + string[][] rows = data + .Select(item => + { + string[] fields = getRow(item); + if (fields.Length != widths.Length) + throw new InvalidOperationException($"Expected {widths.Length} columns, but found {fields.Length}: {string.Join(", ", fields)}"); + + for (int i = 0; i < fields.Length; i++) + widths[i] = Math.Max(widths[i], fields[i].Length); + + return fields; + }) + .ToArray(); + + // render fields + List<string[]> lines = new List<string[]>(rows.Length + 2) + { + header, + header.Select((value, i) => "".PadRight(widths[i], '-')).ToArray() + }; + lines.AddRange(rows); + + return string.Join( + Environment.NewLine, + lines.Select(line => string.Join(" | ", line.Select((field, i) => field.PadRight(widths[i], ' ')).ToArray()) + ) + ); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs b/src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs new file mode 100644 index 00000000..4e62cf77 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/DownMineLevelCommand.cs @@ -0,0 +1,28 @@ +using StardewModdingAPI; +using StardewValley; +using StardewValley.Locations; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which moves the player to the next mine level.</summary> + internal class DownMineLevelCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public DownMineLevelCommand() + : base("world_downminelevel", "Goes down one mine level.") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + int level = (Game1.currentLocation as MineShaft)?.mineLevel ?? 0; + monitor.Log($"OK, warping you to mine level {level + 1}.", LogLevel.Info); + Game1.enterMine(false, level + 1, ""); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs b/src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs new file mode 100644 index 00000000..13d08398 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/FreezeTimeCommand.cs @@ -0,0 +1,67 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which freezes the current time.</summary> + internal class FreezeTimeCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>The time of day at which to freeze time.</summary> + internal static int FrozenTime; + + /// <summary>Whether to freeze time.</summary> + private bool FreezeTime; + + + /********* + ** Accessors + *********/ + /// <summary>Whether the command needs to perform logic when the game updates.</summary> + public override bool NeedsUpdate => this.FreezeTime; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public FreezeTimeCommand() + : base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + if (args.Any()) + { + // parse arguments + if (!args.TryGetInt(0, "value", out int value, min: 0, max: 1)) + return; + + // handle + this.FreezeTime = value == 1; + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); + } + else + { + this.FreezeTime = !this.FreezeTime; + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, time is now {(this.FreezeTime ? "frozen" : "resumed")}.", LogLevel.Info); + } + } + + /// <summary>Perform any logic needed on update tick.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + public override void Update(IMonitor monitor) + { + if (this.FreezeTime) + Game1.timeOfDay = FreezeTimeCommand.FrozenTime; + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/SetDayCommand.cs b/src/TrainerMod/Framework/Commands/World/SetDayCommand.cs new file mode 100644 index 00000000..54267384 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/SetDayCommand.cs @@ -0,0 +1,39 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which sets the current day.</summary> + internal class SetDayCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetDayCommand() + : base("world_setday", "Sets the day to the specified value.\n\nUsage: world_setday <value>.\n- value: the target day (a number from 1 to 28).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) + { + monitor.Log($"The current date is {Game1.currentSeason} {Game1.dayOfMonth}. Specify a value to change the day.", LogLevel.Info); + return; + } + + // parse arguments + if (!args.TryGetInt(0, "day", out int day, min: 1, max: 28)) + return; + + // handle + Game1.dayOfMonth = day; + monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs b/src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs new file mode 100644 index 00000000..225ec091 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/SetMineLevelCommand.cs @@ -0,0 +1,33 @@ +using System; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which moves the player to the given mine level.</summary> + internal class SetMineLevelCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetMineLevelCommand() + : base("world_setminelevel", "Sets the mine level?\n\nUsage: world_setminelevel <value>\n- value: The target level (a number starting at 1).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // parse arguments + if (!args.TryGetInt(0, "mine level", out int level, min: 1)) + return; + + // handle + level = Math.Max(1, level); + monitor.Log($"OK, warping you to mine level {level}.", LogLevel.Info); + Game1.enterMine(true, level, ""); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs b/src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs new file mode 100644 index 00000000..96c3d920 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/SetSeasonCommand.cs @@ -0,0 +1,46 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which sets the current season.</summary> + internal class SetSeasonCommand : TrainerCommand + { + /********* + ** Properties + *********/ + /// <summary>The valid season names.</summary> + private readonly string[] ValidSeasons = { "winter", "spring", "summer", "fall" }; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetSeasonCommand() + : base("world_setseason", "Sets the season to the specified value.\n\nUsage: world_setseason <season>\n- season: the target season (one of 'spring', 'summer', 'fall', 'winter').") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) + { + monitor.Log($"The current season is {Game1.currentSeason}. Specify a value to change it.", LogLevel.Info); + return; + } + + // parse arguments + if (!args.TryGet(0, "season", out string season, oneOf: this.ValidSeasons)) + return; + + // handle + Game1.currentSeason = season; + monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs b/src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs new file mode 100644 index 00000000..c827ea5e --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/SetTimeCommand.cs @@ -0,0 +1,40 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which sets the current time.</summary> + internal class SetTimeCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetTimeCommand() + : base("world_settime", "Sets the time to the specified value.\n\nUsage: world_settime <value>\n- value: the target time in military time (like 0600 for 6am and 1800 for 6pm).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) + { + monitor.Log($"The current time is {Game1.timeOfDay}. Specify a value to change it.", LogLevel.Info); + return; + } + + // parse arguments + if (!args.TryGetInt(0, "time", out int time, min: 600, max: 2600)) + return; + + // handle + Game1.timeOfDay = time; + FreezeTimeCommand.FrozenTime = Game1.timeOfDay; + monitor.Log($"OK, the time is now {Game1.timeOfDay.ToString().PadLeft(4, '0')}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/Commands/World/SetYearCommand.cs b/src/TrainerMod/Framework/Commands/World/SetYearCommand.cs new file mode 100644 index 00000000..760fc170 --- /dev/null +++ b/src/TrainerMod/Framework/Commands/World/SetYearCommand.cs @@ -0,0 +1,39 @@ +using System.Linq; +using StardewModdingAPI; +using StardewValley; + +namespace TrainerMod.Framework.Commands.World +{ + /// <summary>A command which sets the current year.</summary> + internal class SetYearCommand : TrainerCommand + { + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public SetYearCommand() + : base("world_setyear", "Sets the year to the specified value.\n\nUsage: world_setyear <year>\n- year: the target year (a number starting from 1).") { } + + /// <summary>Handle the command.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="command">The command name.</param> + /// <param name="args">The command arguments.</param> + public override void Handle(IMonitor monitor, string command, ArgumentParser args) + { + // no-argument mode + if (!args.Any()) + { + monitor.Log($"The current year is {Game1.year}. Specify a value to change the year.", LogLevel.Info); + return; + } + + // parse arguments + if (!args.TryGetInt(0, "year", out int year, min: 1)) + return; + + // handle + Game1.year = year; + monitor.Log($"OK, the year is now {Game1.year}.", LogLevel.Info); + } + } +} diff --git a/src/TrainerMod/Framework/ItemData/ItemType.cs b/src/TrainerMod/Framework/ItemData/ItemType.cs new file mode 100644 index 00000000..423455e9 --- /dev/null +++ b/src/TrainerMod/Framework/ItemData/ItemType.cs @@ -0,0 +1,39 @@ +namespace TrainerMod.Framework.ItemData +{ + /// <summary>An item type that can be searched and added to the player through the console.</summary> + internal enum ItemType + { + /// <summary>A big craftable object in <see cref="StardewValley.Game1.bigCraftablesInformation"/></summary> + BigCraftable, + + /// <summary>A <see cref="Boots"/> item.</summary> + Boots, + + /// <summary>A fish item.</summary> + Fish, + + /// <summary>A <see cref="Wallpaper"/> flooring item.</summary> + Flooring, + + /// <summary>A <see cref="Furniture"/> item.</summary> + Furniture, + + /// <summary>A <see cref="Hat"/> item.</summary> + Hat, + + /// <summary>Any object in <see cref="StardewValley.Game1.objectInformation"/> (except rings).</summary> + Object, + + /// <summary>A <see cref="Ring"/> item.</summary> + Ring, + + /// <summary>A <see cref="Tool"/> tool.</summary> + Tool, + + /// <summary>A <see cref="Wallpaper"/> wall item.</summary> + Wallpaper, + + /// <summary>A <see cref="StardewValley.Tools.MeleeWeapon"/> or <see cref="StardewValley.Tools.Slingshot"/> item.</summary> + Weapon + } +} diff --git a/src/TrainerMod/Framework/ItemData/SearchableItem.cs b/src/TrainerMod/Framework/ItemData/SearchableItem.cs new file mode 100644 index 00000000..146da1a8 --- /dev/null +++ b/src/TrainerMod/Framework/ItemData/SearchableItem.cs @@ -0,0 +1,41 @@ +using StardewValley; + +namespace TrainerMod.Framework.ItemData +{ + /// <summary>A game item with metadata.</summary> + internal class SearchableItem + { + /********* + ** Accessors + *********/ + /// <summary>The item type.</summary> + public ItemType Type { get; } + + /// <summary>The item instance.</summary> + public Item Item { get; } + + /// <summary>The item's unique ID for its type.</summary> + public int ID { get; } + + /// <summary>The item's default name.</summary> + public string Name => this.Item.Name; + + /// <summary>The item's display name for the current language.</summary> + public string DisplayName => this.Item.DisplayName; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="type">The item type.</param> + /// <param name="id">The unique ID (if different from the item's parent sheet index).</param> + /// <param name="item">The item instance.</param> + public SearchableItem(ItemType type, int id, Item item) + { + this.Type = type; + this.ID = id; + this.Item = item; + } + } +} diff --git a/src/TrainerMod/Framework/ItemRepository.cs b/src/TrainerMod/Framework/ItemRepository.cs new file mode 100644 index 00000000..96d3159e --- /dev/null +++ b/src/TrainerMod/Framework/ItemRepository.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Objects; +using StardewValley.Tools; +using TrainerMod.Framework.ItemData; +using SObject = StardewValley.Object; + +namespace TrainerMod.Framework +{ + /// <summary>Provides methods for searching and constructing items.</summary> + internal class ItemRepository + { + /********* + ** Properties + *********/ + /// <summary>The custom ID offset for items don't have a unique ID in the game.</summary> + private readonly int CustomIDOffset = 1000; + + + /********* + ** Public methods + *********/ + /// <summary>Get all spawnable items.</summary> + public IEnumerable<SearchableItem> GetAll() + { + // get tools + for (int quality = Tool.stone; quality <= Tool.iridium; quality++) + { + yield return new SearchableItem(ItemType.Tool, ToolFactory.axe, ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); + yield return new SearchableItem(ItemType.Tool, ToolFactory.hoe, ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); + yield return new SearchableItem(ItemType.Tool, ToolFactory.pickAxe, ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); + yield return new SearchableItem(ItemType.Tool, ToolFactory.wateringCan, ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); + if (quality != Tool.iridium) + yield return new SearchableItem(ItemType.Tool, ToolFactory.fishingRod, ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); + } + yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset, new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones + yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 1, new Shears()); + yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 2, new Pan()); + + // wallpapers + for (int id = 0; id < 112; id++) + yield return new SearchableItem(ItemType.Wallpaper, id, new Wallpaper(id)); + + // flooring + for (int id = 0; id < 40; id++) + yield return new SearchableItem(ItemType.Flooring, id, new Wallpaper(id, isFloor: true)); + + // equipment + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys) + yield return new SearchableItem(ItemType.Boots, id, new Boots(id)); + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys) + yield return new SearchableItem(ItemType.Hat, id, new Hat(id)); + foreach (int id in Game1.objectInformation.Keys) + { + if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) + yield return new SearchableItem(ItemType.Ring, id, new Ring(id)); + } + + // weapons + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys) + { + Item weapon = (id >= 32 && id <= 34) + ? (Item)new Slingshot(id) + : new MeleeWeapon(id); + yield return new SearchableItem(ItemType.Weapon, id, weapon); + } + + // furniture + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Furniture").Keys) + { + if (id == 1466 || id == 1468) + yield return new SearchableItem(ItemType.Furniture, id, new TV(id, Vector2.Zero)); + else + yield return new SearchableItem(ItemType.Furniture, id, new Furniture(id, Vector2.Zero)); + } + + // fish + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Fish").Keys) + yield return new SearchableItem(ItemType.Fish, id, new SObject(id, 999)); + + // craftables + foreach (int id in Game1.bigCraftablesInformation.Keys) + yield return new SearchableItem(ItemType.BigCraftable, id, new SObject(Vector2.Zero, id)); + + // objects + foreach (int id in Game1.objectInformation.Keys) + { + if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) + continue; // handled separated + + SObject item = new SObject(id, 1); + yield return new SearchableItem(ItemType.Object, id, item); + + // fruit products + if (item.category == SObject.FruitsCategory) + { + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, new SObject(348, 1) + { + name = $"{item.Name} Wine", + price = item.price * 3, + preserve = SObject.PreserveType.Wine, + preservedParentSheetIndex = item.parentSheetIndex + }); + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, new SObject(344, 1) + { + name = $"{item.Name} Jelly", + price = 50 + item.Price * 2, + preserve = SObject.PreserveType.Jelly, + preservedParentSheetIndex = item.parentSheetIndex + }); + } + + // vegetable products + else if (item.category == SObject.VegetableCategory) + { + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, new SObject(350, 1) + { + name = $"{item.Name} Juice", + price = (int)(item.price * 2.25d), + preserve = SObject.PreserveType.Juice, + preservedParentSheetIndex = item.parentSheetIndex + }); + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, new SObject(342, 1) + { + name = $"Pickled {item.Name}", + price = 50 + item.Price * 2, + preserve = SObject.PreserveType.Pickle, + preservedParentSheetIndex = item.parentSheetIndex + }); + } + + // flower honey + else if (item.category == SObject.flowersCategory) + { + // get honey type + SObject.HoneyType? type = null; + switch (item.parentSheetIndex) + { + case 376: + type = SObject.HoneyType.Poppy; + break; + case 591: + type = SObject.HoneyType.Tulip; + break; + case 593: + type = SObject.HoneyType.SummerSpangle; + break; + case 595: + type = SObject.HoneyType.FairyRose; + break; + case 597: + type = SObject.HoneyType.BlueJazz; + break; + case 421: // sunflower standing in for all other flowers + type = SObject.HoneyType.Wild; + break; + } + + // yield honey + if (type != null) + { + SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false) + { + name = "Wild Honey", + honeyType = type + }; + if (type != SObject.HoneyType.Wild) + { + honey.name = $"{item.Name} Honey"; + honey.price += item.price * 2; + } + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, honey); + } + } + } + } + } +} diff --git a/src/TrainerMod/Properties/AssemblyInfo.cs b/src/TrainerMod/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..0b19e78a --- /dev/null +++ b/src/TrainerMod/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("TrainerMod")] +[assembly: AssemblyDescription("")] +[assembly: Guid("76791e28-b1b5-407c-82d6-50c3e5b7e037")]
\ No newline at end of file diff --git a/src/TrainerMod/TrainerMod.cs b/src/TrainerMod/TrainerMod.cs new file mode 100644 index 00000000..5db02cd6 --- /dev/null +++ b/src/TrainerMod/TrainerMod.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI; +using StardewModdingAPI.Events; +using TrainerMod.Framework.Commands; + +namespace TrainerMod +{ + /// <summary>The main entry point for the mod.</summary> + public class TrainerMod : Mod + { + /********* + ** Properties + *********/ + /// <summary>The commands to handle.</summary> + private ITrainerCommand[] Commands; + + + /********* + ** Public methods + *********/ + /// <summary>The mod entry point, called after the mod is first loaded.</summary> + /// <param name="helper">Provides simplified APIs for writing mods.</param> + public override void Entry(IModHelper helper) + { + // register commands + this.Commands = this.ScanForCommands().ToArray(); + foreach (ITrainerCommand command in this.Commands) + helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args)); + + // hook events + GameEvents.UpdateTick += this.GameEvents_UpdateTick; + } + + + /********* + ** Private methods + *********/ + /// <summary>The method invoked when the game updates its state.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + private void GameEvents_UpdateTick(object sender, EventArgs e) + { + if (!Context.IsWorldReady) + return; + + foreach (ITrainerCommand command in this.Commands) + { + if (command.NeedsUpdate) + command.Update(this.Monitor); + } + } + + /// <summary>Handle a TrainerMod command.</summary> + /// <param name="command">The command to invoke.</param> + /// <param name="commandName">The command name specified by the user.</param> + /// <param name="args">The command arguments.</param> + private void HandleCommand(ITrainerCommand command, string commandName, string[] args) + { + ArgumentParser argParser = new ArgumentParser(commandName, args, this.Monitor); + command.Handle(this.Monitor, commandName, argParser); + } + + /// <summary>Find all commands in the assembly.</summary> + private IEnumerable<ITrainerCommand> ScanForCommands() + { + return ( + from type in this.GetType().Assembly.GetTypes() + where !type.IsAbstract && typeof(ITrainerCommand).IsAssignableFrom(type) + select (ITrainerCommand)Activator.CreateInstance(type) + ); + } + } +} diff --git a/src/TrainerMod/TrainerMod.csproj b/src/TrainerMod/TrainerMod.csproj new file mode 100644 index 00000000..73b40050 --- /dev/null +++ b/src/TrainerMod/TrainerMod.csproj @@ -0,0 +1,104 @@ +<?xml version="1.0" encoding="utf-8"?> +<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> + <PropertyGroup> + <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> + <Platform Condition=" '$(Platform)' == '' ">x86</Platform> + <ProjectGuid>{28480467-1A48-46A7-99F8-236D95225359}</ProjectGuid> + <OutputType>Library</OutputType> + <AppDesignerFolder>Properties</AppDesignerFolder> + <RootNamespace>TrainerMod</RootNamespace> + <AssemblyName>TrainerMod</AssemblyName> + <TargetFrameworkVersion>v4.5</TargetFrameworkVersion> + <FileAlignment>512</FileAlignment> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' "> + <DebugSymbols>true</DebugSymbols> + <DebugType>full</DebugType> + <Optimize>true</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Debug\Mods\TrainerMod\</OutputPath> + <DefineConstants>DEBUG;TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + <PlatformTarget>x86</PlatformTarget> + <Prefer32Bit>false</Prefer32Bit> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + </PropertyGroup> + <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' "> + <DebugType>pdbonly</DebugType> + <Optimize>true</Optimize> + <OutputPath>$(SolutionDir)\..\bin\Release\Mods\TrainerMod\</OutputPath> + <DefineConstants>TRACE</DefineConstants> + <ErrorReport>prompt</ErrorReport> + <WarningLevel>4</WarningLevel> + <Prefer32Bit>false</Prefer32Bit> + <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow> + <PlatformTarget>x86</PlatformTarget> + </PropertyGroup> + <ItemGroup> + <Reference Include="Newtonsoft.Json, Version=8.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> + <HintPath>..\packages\Newtonsoft.Json.8.0.3\lib\net45\Newtonsoft.Json.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="System" /> + <Reference Include="System.Core" /> + <Reference Include="System.Xml.Linq" /> + <Reference Include="Microsoft.CSharp" /> + <Reference Include="System.Data" /> + <Reference Include="System.Xml" /> + </ItemGroup> + <ItemGroup> + <Compile Include="..\GlobalAssemblyInfo.cs"> + <Link>Properties\GlobalAssemblyInfo.cs</Link> + </Compile> + <Compile Include="Framework\Commands\ArgumentParser.cs" /> + <Compile Include="Framework\Commands\Other\ShowDataFilesCommand.cs" /> + <Compile Include="Framework\Commands\Other\ShowGameFilesCommand.cs" /> + <Compile Include="Framework\Commands\Other\DebugCommand.cs" /> + <Compile Include="Framework\Commands\Player\ListItemTypesCommand.cs" /> + <Compile Include="Framework\Commands\Player\ListItemsCommand.cs" /> + <Compile Include="Framework\Commands\Player\AddCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetStyleCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetColorCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetSpeedCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetLevelCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetMaxHealthCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetMaxStaminaCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetHealthCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetImmunityCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetStaminaCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetNameCommand.cs" /> + <Compile Include="Framework\Commands\Player\SetMoneyCommand.cs" /> + <Compile Include="Framework\Commands\Saves\LoadCommand.cs" /> + <Compile Include="Framework\Commands\Saves\SaveCommand.cs" /> + <Compile Include="Framework\Commands\TrainerCommand.cs" /> + <Compile Include="Framework\Commands\World\SetMineLevelCommand.cs" /> + <Compile Include="Framework\Commands\World\DownMineLevelCommand.cs" /> + <Compile Include="Framework\Commands\World\SetYearCommand.cs" /> + <Compile Include="Framework\Commands\World\SetSeasonCommand.cs" /> + <Compile Include="Framework\Commands\World\SetDayCommand.cs" /> + <Compile Include="Framework\Commands\World\SetTimeCommand.cs" /> + <Compile Include="Framework\Commands\World\FreezeTimeCommand.cs" /> + <Compile Include="Framework\ItemData\ItemType.cs" /> + <Compile Include="Framework\Commands\ITrainerCommand.cs" /> + <Compile Include="Framework\ItemData\SearchableItem.cs" /> + <Compile Include="Framework\ItemRepository.cs" /> + <Compile Include="TrainerMod.cs" /> + <Compile Include="Properties\AssemblyInfo.cs" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI\StardewModdingAPI.csproj"> + <Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project> + <Name>StardewModdingAPI</Name> + <Private>False</Private> + </ProjectReference> + </ItemGroup> + <ItemGroup> + <None Include="manifest.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Include="packages.config" /> + </ItemGroup> + <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> + <Import Project="$(SolutionDir)\common.targets" /> +</Project>
\ No newline at end of file diff --git a/src/TrainerMod/manifest.json b/src/TrainerMod/manifest.json new file mode 100644 index 00000000..20b40f8a --- /dev/null +++ b/src/TrainerMod/manifest.json @@ -0,0 +1,13 @@ +{ + "Name": "Trainer Mod", + "Author": "SMAPI", + "Version": { + "MajorVersion": 1, + "MinorVersion": 15, + "PatchVersion": 4, + "Build": null + }, + "Description": "Adds SMAPI console commands that let you manipulate the game.", + "UniqueID": "SMAPI.TrainerMod", + "EntryDll": "TrainerMod.dll" +} diff --git a/src/TrainerMod/packages.config b/src/TrainerMod/packages.config new file mode 100644 index 00000000..75e68e71 --- /dev/null +++ b/src/TrainerMod/packages.config @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<packages> + <package id="Newtonsoft.Json" version="8.0.3" targetFramework="net461" /> +</packages>
\ No newline at end of file diff --git a/src/common.targets b/src/common.targets new file mode 100644 index 00000000..c450b3a5 --- /dev/null +++ b/src/common.targets @@ -0,0 +1,106 @@ +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <!-- load dev settings --> + <Import Condition="$(OS) != 'Windows_NT' AND Exists('$(HOME)\stardewvalley.targets')" Project="$(HOME)\stardewvalley.targets" /> + <Import Condition="$(OS) == 'Windows_NT' AND Exists('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" /> + + <!-- find game path --> + <PropertyGroup> + <!-- Linux paths --> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.steam/steam/steamapps/common/Stardew Valley</GamePath> + <!-- Mac paths --> + <GamePath Condition="!Exists('$(GamePath)')">/Applications/Stardew Valley.app/Contents/MacOS</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath> + <!-- Windows paths --> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath> + <GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath> + </PropertyGroup> + + <!-- add references--> + <Choose> + <When Condition="$(OS) == 'Windows_NT'"> + <PropertyGroup> + <DefineConstants>$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants> + </PropertyGroup> + <ItemGroup> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Stardew Valley"> + <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> + <Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private> + </Reference> + <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>False</Private> + <SpecificVersion>False</SpecificVersion> + </Reference> + </ItemGroup> + </When> + <Otherwise> + <PropertyGroup> + <DefineConstants>$(DefineConstants);SMAPI_FOR_UNIX</DefineConstants> + </PropertyGroup> + <ItemGroup> + <Reference Include="MonoGame.Framework"> + <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> + <Private>False</Private> + <SpecificVersion>False</SpecificVersion> + </Reference> + <Reference Include="StardewValley"> + <HintPath>$(GamePath)\StardewValley.exe</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="xTile"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + </Otherwise> + </Choose> + + <!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors --> + <Target Name="BeforeBuild"> + <Error Condition="!Exists('$(GamePath)')" Text="Failed to find the game install path automatically; edit the *.csproj file and manually add a <GamePath> setting with the full directory path containing the Stardew Valley executable." /> + </Target> + + <!-- copy files into game directory and enable debugging (only in debug mode) --> + <Target Name="AfterBuild"> + <CallTarget Targets="CopySMAPI;CopyTrainerMod" Condition="'$(Configuration)' == 'Debug'" /> + </Target> + <Target Name="CopySMAPI" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI'"> + <Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).config.json" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)" /> + <Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)" /> + </Target> + <Target Name="CopyTrainerMod" Condition="'$(MSBuildProjectName)' == 'TrainerMod'"> + <Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\Mods\TrainerMod" /> + <Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\Mods\TrainerMod" Condition="Exists('$(TargetDir)\$(TargetName).pdb')" /> + <Copy SourceFiles="$(TargetDir)\manifest.json" DestinationFolder="$(GamePath)\Mods\TrainerMod" /> + </Target> + + <!-- launch SMAPI on debug --> + <PropertyGroup Condition="$(Configuration) == 'Debug'"> + <StartAction>Program</StartAction> + <StartProgram>$(GamePath)\StardewModdingAPI.exe</StartProgram> + <StartWorkingDirectory>$(GamePath)</StartWorkingDirectory> + </PropertyGroup> + + <!-- Somehow this makes Visual Studio for Mac recognise the previous section. Nobody knows why. --> + <PropertyGroup Condition="'$(RunConfiguration)' == 'Default'" /> +</Project> diff --git a/src/prepare-install-package.targets b/src/prepare-install-package.targets new file mode 100644 index 00000000..44997fef --- /dev/null +++ b/src/prepare-install-package.targets @@ -0,0 +1,47 @@ +<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> + <!-- + + This build task is run from the installer project after all projects have been compiled, and + creates the build package in the bin\Packages folder. + + --> + <Target Name="AfterBuild"> + <PropertyGroup> + <CompiledSmapiPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</CompiledSmapiPath> + <PackagePath>$(SolutionDir)\..\bin\Packaged</PackagePath> + <PackageInternalPath>$(PackagePath)\internal</PackageInternalPath> + </PropertyGroup> + <ItemGroup> + <CompiledMods Include="$(SolutionDir)\..\bin\$(Configuration)\Mods\**\*.*" /> + </ItemGroup> + <!-- reset package directory --> + <RemoveDir Directories="$(PackagePath)" /> + + <!-- copy installer files --> + <Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\install.exe" /> + <Copy SourceFiles="$(TargetDir)\readme.txt" DestinationFiles="$(PackagePath)\README.txt" /> + + <!-- copy SMAPI files for Mono --> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Runtime.Caching.dll" DestinationFolder="$(PackageInternalPath)\Mono" /> + <Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\unix-launcher.sh" DestinationFiles="$(PackageInternalPath)\Mono\StardewModdingAPI" /> + <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 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" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Windows" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Windows" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Windows" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Windows" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Windows" /> + <Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Windows\Mods\%(RecursiveDir)" /> + </Target> +</Project> |