diff options
Diffstat (limited to 'src')
218 files changed, 7358 insertions, 4161 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs index d212876a..e5396018 100644 --- a/src/SMAPI.Installer/Framework/InstallerPaths.cs +++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs @@ -8,8 +8,8 @@ namespace StardewModdingAPI.Installer.Framework /********* ** Accessors *********/ - /// <summary>The directory containing the installer files for the current platform.</summary> - public DirectoryInfo PackageDir { get; } + /// <summary>The directory path containing the files to copy into the game folder.</summary> + public DirectoryInfo BundleDir { get; } /// <summary>The directory containing the installed game.</summary> public DirectoryInfo GameDir { get; } @@ -17,8 +17,8 @@ namespace StardewModdingAPI.Installer.Framework /// <summary>The directory into which to install mods.</summary> public DirectoryInfo ModsDir { get; } - /// <summary>The full path to the directory containing the installer files for the current platform.</summary> - public string PackagePath => this.PackageDir.FullName; + /// <summary>The full path to directory path containing the files to copy into the game folder.</summary> + public string BundlePath => this.BundleDir.FullName; /// <summary>The full path to the directory containing the installed game.</summary> public string GamePath => this.GameDir.FullName; @@ -26,6 +26,9 @@ namespace StardewModdingAPI.Installer.Framework /// <summary>The full path to the directory into which to install mods.</summary> public string ModsPath => this.ModsDir.FullName; + /// <summary>The full path to SMAPI's internal configuration file.</summary> + public string ApiConfigPath { get; } + /// <summary>The full path to the installed SMAPI executable file.</summary> public string ExecutablePath { get; } @@ -43,12 +46,12 @@ namespace StardewModdingAPI.Installer.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="packageDir">The directory path containing the installer files for the current platform.</param> + /// <param name="bundleDir">The directory path containing the files to copy into the game folder.</param> /// <param name="gameDir">The directory path for the installed game.</param> /// <param name="gameExecutableName">The name of the game's executable file for the current platform.</param> - public InstallerPaths(DirectoryInfo packageDir, DirectoryInfo gameDir, string gameExecutableName) + public InstallerPaths(DirectoryInfo bundleDir, DirectoryInfo gameDir, string gameExecutableName) { - this.PackageDir = packageDir; + this.BundleDir = bundleDir; this.GameDir = gameDir; this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods")); @@ -56,6 +59,7 @@ namespace StardewModdingAPI.Installer.Framework this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley"); this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original"); + this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "StardewModdingAPI.config.json"); } } } diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 0aac1da2..d5866c74 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -1,15 +1,18 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; -using System.Reflection; using System.Threading; using Microsoft.Win32; using StardewModdingApi.Installer.Enums; using StardewModdingAPI.Installer.Framework; using StardewModdingAPI.Internal; using StardewModdingAPI.Internal.ConsoleWriting; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.ModScanning; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingApi.Installer { @@ -19,18 +22,19 @@ namespace StardewModdingApi.Installer /********* ** Properties *********/ - /// <summary>The name of the installer file in the package.</summary> - private readonly string InstallerFileName = "install.exe"; - - /// <summary>Mod files which shouldn't be deleted when deploying bundled mods (mod folder name => file names).</summary> - private readonly IDictionary<string, HashSet<string>> ProtectBundledFiles = new Dictionary<string, HashSet<string>>(StringComparer.InvariantCultureIgnoreCase) - { - ["SaveBackup"] = new HashSet<string>(new[] { "backups", "config.json" }, StringComparer.InvariantCultureIgnoreCase) - }; + /// <summary>The absolute path to the directory containing the files to copy into the game folder.</summary> + private readonly string BundlePath; /// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary> private readonly Version Windows7Version = new Version(6, 1); + /// <summary>The mod IDs which the installer should allow as bundled mods.</summary> + private readonly string[] BundledModIDs = new[] + { + "SMAPI.SaveBackup", + "SMAPI.ConsoleCommands" + }; + /// <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> @@ -58,8 +62,12 @@ namespace StardewModdingApi.Installer 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"; + foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) + { + yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; + yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; + } // Windows registry IDictionary<string, string> registryKeys = new Dictionary<string, string> @@ -93,40 +101,42 @@ namespace StardewModdingApi.Installer { string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); - // common - yield return GetInstallPath("0Harmony.dll"); - yield return GetInstallPath("0Harmony.pdb"); - yield return GetInstallPath("Mono.Cecil.dll"); - yield return GetInstallPath("Newtonsoft.Json.dll"); + // current files + yield return GetInstallPath("libgdiplus.dylib"); // Linux/Mac only + yield return GetInstallPath("StardewModdingAPI"); // Linux/Mac only yield return GetInstallPath("StardewModdingAPI.exe"); - yield return GetInstallPath("StardewModdingAPI.config.json"); - yield return GetInstallPath("StardewModdingAPI.metadata.json"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.dll"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.xml"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); - yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); + yield return GetInstallPath("StardewModdingAPI.exe.config"); + yield return GetInstallPath("StardewModdingAPI.exe.mdb"); // Linux/Mac only + yield return GetInstallPath("StardewModdingAPI.pdb"); // Windows only yield return GetInstallPath("StardewModdingAPI.xml"); - 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"); + yield return GetInstallPath("smapi-internal"); // obsolete - yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 + yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // *–2.0 (renamed to ConsoleCommands) - yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.3–1.8 - yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-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-2.5.5 + yield return GetInstallPath("0Harmony.dll"); // moved in 2.8 + yield return GetInstallPath("0Harmony.pdb"); // moved in 2.8 + yield return GetInstallPath("Mono.Cecil.dll"); // moved in 2.8 + yield return GetInstallPath("Newtonsoft.Json.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.config.json"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.crash.marker"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.metadata.json"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.update.marker"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.xml"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 + yield return GetInstallPath("StardewModdingAPI.xml"); // moved in 2.8 + yield return GetInstallPath("System.Numerics.dll"); // moved in 2.8 + yield return GetInstallPath("System.Runtime.Caching.dll"); // moved in 2.8 + yield return GetInstallPath("System.ValueTuple.dll"); // moved in 2.8 + yield return GetInstallPath("steam_appid.txt"); // moved in 2.8 + if (modsDir.Exists) { foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories()) @@ -143,8 +153,10 @@ namespace StardewModdingApi.Installer ** Public methods *********/ /// <summary>Construct an instance.</summary> - public InteractiveInstaller() + /// <param name="bundlePath">The absolute path to the directory containing the files to copy into the game folder.</param> + public InteractiveInstaller(string bundlePath) { + this.BundlePath = bundlePath; this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.AutoDetect); } @@ -319,10 +331,8 @@ namespace StardewModdingApi.Installer } // get folders - DirectoryInfo packageDir = platform.IsMono() - ? new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)) // installer runs from internal folder on Mono - : new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", "Windows")); - paths = new InstallerPaths(packageDir, installDir, EnvironmentUtility.GetExecutableName(platform)); + DirectoryInfo bundleDir = new DirectoryInfo(this.BundlePath); + paths = new InstallerPaths(bundleDir, installDir, EnvironmentUtility.GetExecutableName(platform)); } Console.Clear(); @@ -330,23 +340,11 @@ namespace StardewModdingApi.Installer /********* ** Step 4: validate assumptions *********/ + if (!File.Exists(paths.ExecutablePath)) { - if (!paths.PackageDir.Exists) - { - this.PrintError(platform == Platform.Windows && paths.PackagePath.Contains(Path.GetTempPath()) && paths.PackagePath.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/{paths.PackageDir.Name}' package folder is missing (should be at {paths.PackagePath})." - ); - Console.ReadLine(); - return; - } - - if (!File.Exists(paths.ExecutablePath)) - { - this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); - Console.ReadLine(); - return; - } + this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + Console.ReadLine(); + return; } @@ -438,20 +436,18 @@ namespace StardewModdingApi.Installer { // copy SMAPI files to game dir this.PrintDebug("Adding SMAPI files..."); - foreach (FileInfo sourceFile in paths.PackageDir.EnumerateFiles().Where(this.ShouldCopyFile)) + foreach (FileSystemInfo sourceEntry in paths.BundleDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) { - if (sourceFile.Name == this.InstallerFileName) - continue; - - string targetPath = Path.Combine(paths.GameDir.FullName, sourceFile.Name); - this.InteractivelyDelete(targetPath); - sourceFile.CopyTo(targetPath); + this.InteractivelyDelete(Path.Combine(paths.GameDir.FullName, sourceEntry.Name)); + this.RecursiveCopy(sourceEntry, paths.GameDir); } // replace mod launcher (if possible) if (platform.IsMono()) { this.PrintDebug("Safely replacing game launcher..."); + + // back up & remove current launcher if (File.Exists(paths.UnixLauncherPath)) { if (!File.Exists(paths.UnixBackupLauncherPath)) @@ -460,7 +456,20 @@ namespace StardewModdingApi.Installer this.InteractivelyDelete(paths.UnixLauncherPath); } + // add new launcher File.Move(paths.UnixSmapiLauncherPath, paths.UnixLauncherPath); + + // mark file executable + // (MSBuild doesn't keep permission flags for files zipped in a build task.) + new Process + { + StartInfo = new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"755 \"{paths.UnixLauncherPath}\"", + CreateNoWindow = true + } + }.Start(); } // create mods directory (if needed) @@ -471,60 +480,56 @@ namespace StardewModdingApi.Installer } // add or replace bundled mods - DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(paths.PackageDir.FullName, "Mods")); - if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any()) + DirectoryInfo bundledModsDir = new DirectoryInfo(Path.Combine(paths.BundlePath, "Mods")); + if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any()) { this.PrintDebug("Adding bundled mods..."); - // special case: rename Omegasis' SaveBackup mod + ModToolkit toolkit = new ModToolkit(); + ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath).ToArray(); + foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName)) { - DirectoryInfo oldFolder = new DirectoryInfo(Path.Combine(paths.ModsDir.FullName, "SaveBackup")); - DirectoryInfo newFolder = new DirectoryInfo(Path.Combine(paths.ModsDir.FullName, "AdvancedSaveBackup")); - FileInfo manifest = new FileInfo(Path.Combine(oldFolder.FullName, "manifest.json")); - if (manifest.Exists && !newFolder.Exists && File.ReadLines(manifest.FullName).Any(p => p.IndexOf("Omegasis", StringComparison.InvariantCultureIgnoreCase) != -1)) + // validate source mod + if (sourceMod.Manifest == null) { - this.PrintDebug($" moving {oldFolder.Name} to {newFolder.Name}..."); - this.Move(oldFolder, newFolder.FullName); + this.PrintWarning($" ignored invalid bundled mod {sourceMod.DisplayName}: {sourceMod.ManifestParseError}"); + continue; } - } - - // add bundled mods - foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories()) - { - this.PrintDebug($" adding {sourceDir.Name}..."); - - // init/clear target dir - DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(paths.ModsDir.FullName, sourceDir.Name)); - if (targetDir.Exists) + if (!this.BundledModIDs.Contains(sourceMod.Manifest.UniqueID)) { - this.ProtectBundledFiles.TryGetValue(targetDir.Name, out HashSet<string> protectedFiles); - foreach (FileSystemInfo entry in targetDir.EnumerateFileSystemInfos()) - { - if (protectedFiles == null || !protectedFiles.Contains(entry.Name)) - this.InteractivelyDelete(entry.FullName); - } + this.PrintWarning($" ignored unknown '{sourceMod.DisplayName}' mod in the installer folder. To add mods, put them here instead: {paths.ModsPath}"); + continue; } - else - targetDir.Create(); + + // find target folder + ModFolder targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.InvariantCultureIgnoreCase) == true); + DirectoryInfo defaultTargetFolder = new DirectoryInfo(Path.Combine(paths.ModsPath, sourceMod.Directory.Name)); + DirectoryInfo targetFolder = targetMod?.Directory ?? defaultTargetFolder; + this.PrintDebug(targetFolder.FullName == defaultTargetFolder.FullName + ? $" adding {sourceMod.Manifest.Name}..." + : $" adding {sourceMod.Manifest.Name} to {Path.Combine(paths.ModsDir.Name, PathUtilities.GetRelativePath(paths.ModsPath, targetFolder.FullName))}..." + ); + + // remove existing folder + if (targetFolder.Exists) + this.InteractivelyDelete(targetFolder.FullName); // copy files - foreach (FileInfo sourceFile in sourceDir.EnumerateFiles().Where(this.ShouldCopyFile)) - sourceFile.CopyTo(Path.Combine(targetDir.FullName, sourceFile.Name)); + this.RecursiveCopy(sourceMod.Directory, paths.ModsDir, filter: this.ShouldCopy); } + } - // set SMAPI's color scheme if defined - if (scheme != MonitorColorScheme.AutoDetect) - { - string configPath = Path.Combine(paths.GamePath, "StardewModdingAPI.config.json"); - string text = File - .ReadAllText(configPath) - .Replace(@"""ColorScheme"": ""AutoDetect""", $@"""ColorScheme"": ""{scheme}"""); - File.WriteAllText(configPath, text); - } + // set SMAPI's color scheme if defined + if (scheme != MonitorColorScheme.AutoDetect) + { + string text = File + .ReadAllText(paths.ApiConfigPath) + .Replace(@"""ColorScheme"": ""AutoDetect""", $@"""ColorScheme"": ""{scheme}"""); + File.WriteAllText(paths.ApiConfigPath, text); } // remove obsolete appdata mods - this.InteractivelyRemoveAppDataMods(paths.ModsDir, packagedModsDir); + this.InteractivelyRemoveAppDataMods(paths.ModsDir, bundledModsDir); } } Console.WriteLine(); @@ -690,6 +695,35 @@ namespace StardewModdingApi.Installer } } + /// <summary>Recursively copy a directory or file.</summary> + /// <param name="source">The file or folder to copy.</param> + /// <param name="targetFolder">The folder to copy into.</param> + /// <param name="filter">A filter which matches directories and files to copy, or <c>null</c> to match all.</param> + private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter = null) + { + if (filter != null && !filter(source)) + return; + + if (!targetFolder.Exists) + targetFolder.Create(); + + switch (source) + { + case FileInfo sourceFile: + sourceFile.CopyTo(Path.Combine(targetFolder.FullName, sourceFile.Name)); + break; + + case DirectoryInfo sourceDir: + DirectoryInfo targetSubfolder = new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)); + foreach (var entry in sourceDir.EnumerateFileSystemInfos()) + this.RecursiveCopy(entry, targetSubfolder, filter); + break; + + default: + throw new NotSupportedException($"Unknown filesystem info type '{source.GetType().FullName}'."); + } + } + /// <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> /// <remarks>This method is mirred from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks> @@ -871,7 +905,7 @@ namespace StardewModdingApi.Installer 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().Where(this.ShouldCopyFile)) + foreach (FileSystemInfo entry in modDir.EnumerateFileSystemInfos().Where(this.ShouldCopy)) { // get type bool isDir = entry is DirectoryInfo; @@ -928,22 +962,26 @@ namespace StardewModdingApi.Installer Directory.CreateDirectory(newPath); DirectoryInfo directory = (DirectoryInfo)entry; - foreach (FileSystemInfo child in directory.EnumerateFileSystemInfos().Where(this.ShouldCopyFile)) + foreach (FileSystemInfo child in directory.EnumerateFileSystemInfos().Where(this.ShouldCopy)) this.Move(child, Path.Combine(newPath, child.Name)); directory.Delete(recursive: true); } } - /// <summary>Get whether a file should be copied when moving a folder.</summary> - /// <param name="file">The file info.</param> - private bool ShouldCopyFile(FileSystemInfo file) + /// <summary>Get whether a file or folder should be copied from the installer files.</summary> + /// <param name="entry">The file or folder info.</param> + private bool ShouldCopy(FileSystemInfo entry) { - // ignore Mac symlink - if (file is FileInfo && file.Name == "mcs") - return false; - - return true; + switch (entry.Name) + { + case "mcs": + return false; // ignore Mac symlink + case "Mods": + return false; // Mods folder handled separately + default: + return true; + } } } } diff --git a/src/SMAPI.Installer/Program.cs b/src/SMAPI.Installer/Program.cs index 8f328ecf..0ca5aea0 100644 --- a/src/SMAPI.Installer/Program.cs +++ b/src/SMAPI.Installer/Program.cs @@ -1,17 +1,82 @@ -namespace StardewModdingApi.Installer +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.IO.Compression; +using System.Reflection; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingApi.Installer { /// <summary>The entry point for SMAPI's install and uninstall console app.</summary> internal class Program { /********* + ** Properties + *********/ + /// <summary>The absolute path of the installer folder.</summary> + [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] + private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + + /// <summary>The absolute path of the folder containing the unzipped installer files.</summary> + private static readonly string ExtractedBundlePath = Path.Combine(Path.GetTempPath(), $"SMAPI-installer-{Guid.NewGuid():N}"); + + /// <summary>The absolute path for referenced assemblies.</summary> + private static readonly string InternalFilesPath = Path.Combine(Program.ExtractedBundlePath, "smapi-internal"); + + /********* ** 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(); + // find install bundle + PlatformID platform = Environment.OSVersion.Platform; + FileInfo zipFile = new FileInfo(Path.Combine(Program.InstallerPath, $"{(platform == PlatformID.Win32NT ? "windows" : "unix")}-install.dat")); + if (!zipFile.Exists) + { + Console.WriteLine($"Oops! Some of the installer files are missing; try redownloading the installer. (Missing file: {zipFile.FullName})"); + Console.ReadLine(); + return; + } + + // unzip bundle into temp folder + DirectoryInfo bundleDir = new DirectoryInfo(Program.ExtractedBundlePath); + Console.WriteLine("Extracting install files..."); + ZipFile.ExtractToDirectory(zipFile.FullName, bundleDir.FullName); + + // set up assembly resolution + AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; + + // launch installer + var installer = new InteractiveInstaller(bundleDir.FullName); installer.Run(args); } + + /********* + ** Private methods + *********/ + /// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e) + { + try + { + AssemblyName name = new AssemblyName(e.Name); + foreach (FileInfo dll in new DirectoryInfo(Program.InternalFilesPath).EnumerateFiles("*.dll")) + { + if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.InvariantCultureIgnoreCase)) + return Assembly.LoadFrom(dll.FullName); + } + return null; + } + catch (Exception ex) + { + Console.WriteLine($"Error resolving assembly: {ex}"); + return null; + } + } } } diff --git a/src/SMAPI.Installer/readme.txt b/src/SMAPI.Installer/README.txt index 2ee5473c..79c90cc0 100644 --- a/src/SMAPI.Installer/readme.txt +++ b/src/SMAPI.Installer/README.txt @@ -16,7 +16,7 @@ SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separ Player's guide -------------------------------- -See https://stardewvalleywiki.com/Modding:Player_Guide +See https://stardewvalleywiki.com/Modding:Player_Guide for help installing SMAPI, adding mods, etc. Manual install @@ -24,15 +24,21 @@ 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. +1. Unzip "internal/windows-install.dat" (on Windows) or "internal/unix-install.dat" (on Linux/Mac). + You can change '.dat' to '.zip', it's just a normal zip file renamed to prevent confusion. +2. Copy the files from the folder you just unzipped into your game folder. The + `StardewModdingAPI.exe` file should be right next to the game's executable. +3. - 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. + +When installing on Linux or Mac: +- Make sure Mono is installed (normally the installer checks for you). While it's not required, + many mods won't work correctly without it. (Specifically, mods which load PNG images may crash or + freeze the game.) +- To configure the color scheme, edit the `smapi-internal/StardewModdingAPI.config.json` file and + see instructions there for the 'ColorScheme' setting. diff --git a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj index e82c6093..8000e4e7 100644 --- a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj +++ b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj @@ -34,6 +34,8 @@ </PropertyGroup> <ItemGroup> <Reference Include="System" /> + <Reference Include="System.IO.Compression" /> + <Reference Include="System.IO.Compression.FileSystem" /> </ItemGroup> <ItemGroup> <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> @@ -46,11 +48,17 @@ <Compile Include="Properties\AssemblyInfo.cs" /> </ItemGroup> <ItemGroup> - <Content Include="readme.txt"> + <Content Include="README.txt"> <CopyToOutputDirectory>Always</CopyToOutputDirectory> </Content> </ItemGroup> <ItemGroup> + <Content Include="windows-exe-config.xml"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <None Include="windows-install.bat"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> <None Include="unix-install.sh"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> @@ -58,6 +66,12 @@ <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj"> + <Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project> + <Name>StardewModdingAPI.Toolkit</Name> + </ProjectReference> + </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> diff --git a/src/SMAPI.Installer/unix-install.sh b/src/SMAPI.Installer/unix-install.sh index df02bb37..e3a5d8cc 100644 --- a/src/SMAPI.Installer/unix-install.sh +++ b/src/SMAPI.Installer/unix-install.sh @@ -14,8 +14,8 @@ fi # validate Mono & run installer if $COMMAND mono >/dev/null 2>&1; then - mono internal/Mono/install.exe + mono internal/unix-install.exe else - echo "Oops! Looks like Mono isn't installed. Please install Mono from http://mono-project.com, reboot, and run this installer again." + echo "Oops! Looks like Mono isn't installed. Please install Mono from https://mono-project.com, reboot, and run this installer again." read fi diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh index 1e969c20..3c332472 100644 --- a/src/SMAPI.Installer/unix-launcher.sh +++ b/src/SMAPI.Installer/unix-launcher.sh @@ -62,27 +62,27 @@ else fi # open SMAPI in terminal - if $COMMAND x-terminal-emulator 2>/dev/null; then + if $COMMAND xterm 2>/dev/null; then + xterm -e "$LAUNCHER" + elif $COMMAND x-terminal-emulator 2>/dev/null; then # Terminator converts -e to -x when used through x-terminal-emulator for some reason (per # `man terminator`), which causes an "unable to find shell" error. If x-terminal-emulator # is mapped to Terminator, invoke it directly instead. if [[ "$(readlink -e $(which x-terminal-emulator))" == *"/terminator" ]]; then - terminator -e "$LAUNCHER" + terminator -e "sh -c 'TERM=xterm $LAUNCHER'" else - x-terminal-emulator -e "$LAUNCHER" + x-terminal-emulator -e "sh -c 'TERM=xterm $LAUNCHER'" fi - elif $COMMAND xterm 2>/dev/null; then - xterm -e "$LAUNCHER" elif $COMMAND xfce4-terminal 2>/dev/null; then - xfce4-terminal -e "env TERM=xterm; $LAUNCHER" + xfce4-terminal -e "sh -c 'TERM=xterm $LAUNCHER'" elif $COMMAND gnome-terminal 2>/dev/null; then - gnome-terminal -e "env TERM=xterm; $LAUNCHER" + gnome-terminal -e "sh -c 'TERM=xterm $LAUNCHER'" elif $COMMAND konsole 2>/dev/null; then konsole -p Environment=TERM=xterm -e "$LAUNCHER" elif $COMMAND terminal 2>/dev/null; then - terminal -e "$LAUNCHER" + terminal -e "sh -c 'TERM=xterm $LAUNCHER'" else - $LAUNCHER + sh -c 'TERM=xterm $LAUNCHER' fi # some Linux users get error 127 (command not found) from the above block, even though diff --git a/src/SMAPI.Installer/windows-exe-config.xml b/src/SMAPI.Installer/windows-exe-config.xml new file mode 100644 index 00000000..386c7f1a --- /dev/null +++ b/src/SMAPI.Installer/windows-exe-config.xml @@ -0,0 +1,5 @@ +<configuration> + <runtime> + <loadFromRemoteSources enabled="true"/> + </runtime> +</configuration> diff --git a/src/SMAPI.Installer/windows-install.bat b/src/SMAPI.Installer/windows-install.bat new file mode 100644 index 00000000..7a8b409b --- /dev/null +++ b/src/SMAPI.Installer/windows-install.bat @@ -0,0 +1 @@ +START /WAIT /B internal/windows-install.exe diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index c6241ecb..4d93df73 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -6,9 +6,9 @@ <ItemGroup> <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" /> - <PackageReference Include="NUnit" Version="3.10.1" /> - <PackageReference Include="NUnit3TestAdapter" Version="3.10.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" /> + <PackageReference Include="NUnit" Version="3.11.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="3.11.0" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index f4738d71..7ff66695 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -3,8 +3,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using System.Web.Script.Serialization; -using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialisation.Models; namespace StardewModdingAPI.ModBuildConfig.Framework { @@ -107,41 +107,10 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /// <exception cref="UserErrorException">The manifest is missing or invalid.</exception> public string GetManifestVersion() { - // get manifest file - if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile)) + if (!this.Files.TryGetValue(this.ManifestFileName, out FileInfo manifestFile) || !new JsonHelper().ReadJsonFileIfExists(manifestFile.FullName, out Manifest manifest)) throw new InvalidOperationException($"The mod does not have a {this.ManifestFileName} file."); // shouldn't happen since we validate in constructor - // read content - string json = File.ReadAllText(manifestFile.FullName); - if (string.IsNullOrWhiteSpace(json)) - throw new UserErrorException("The mod's manifest must not be empty."); - - // parse JSON - IDictionary<string, object> data; - try - { - data = this.Parse(json); - } - catch (Exception ex) - { - throw new UserErrorException($"The mod's manifest couldn't be parsed. It doesn't seem to be valid JSON.\n{ex}"); - } - - // get version field - object versionObj = data.ContainsKey("Version") ? data["Version"] : null; - if (versionObj == null) - throw new UserErrorException("The mod's manifest must have a version field."); - - // get version string - if (versionObj is IDictionary<string, object> versionFields) // SMAPI 1.x - { - int major = versionFields.ContainsKey("MajorVersion") ? (int)versionFields["MajorVersion"] : 0; - int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0; - int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0; - string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null; - return new SemanticVersion(major, minor, patch, tag).ToString(); - } - return new SemanticVersion(versionObj.ToString()).ToString(); // SMAPI 2.0+ + return manifest.Version.ToString(); } @@ -174,24 +143,6 @@ namespace StardewModdingAPI.ModBuildConfig.Framework || ignoreFilePatterns.Any(p => p.IsMatch(relativePath)); } - /// <summary>Get a case-insensitive dictionary matching the given JSON.</summary> - /// <param name="json">The JSON to parse.</param> - private IDictionary<string, object> Parse(string json) - { - IDictionary<string, object> MakeCaseInsensitive(IDictionary<string, object> dict) - { - foreach (var field in dict.ToArray()) - { - if (field.Value is IDictionary<string, object> value) - dict[field.Key] = MakeCaseInsensitive(value); - } - return new Dictionary<string, object>(dict, StringComparer.InvariantCultureIgnoreCase); - } - - IDictionary<string, object> data = (IDictionary<string, object>)new JavaScriptSerializer().DeserializeObject(json); - return MakeCaseInsensitive(data); - } - /// <summary>Get whether a string is equal to another case-insensitively.</summary> /// <param name="str">The string value.</param> /// <param name="other">The string to compare with.</param> diff --git a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs index d6f8dd7f..e051bfbd 100644 --- a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs +++ b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs @@ -2,5 +2,5 @@ using System.Reflection; [assembly: AssemblyTitle("SMAPI.ModBuildConfig")] [assembly: AssemblyDescription("")] -[assembly: AssemblyVersion("2.1.0.0")] -[assembly: AssemblyFileVersion("2.1.0.0")] +[assembly: AssemblyVersion("2.2.0")] +[assembly: AssemblyFileVersion("2.2.0")] diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj index 6a52daac..f068b480 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj @@ -56,6 +56,10 @@ <Content Include="assets\nuget-icon.png" /> </ItemGroup> <ItemGroup> + <ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj"> + <Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project> + <Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name> + </ProjectReference> <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj"> <Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project> <Name>StardewModdingAPI.Toolkit</Name> diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index d1c8a4eb..e6c3fa57 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -42,6 +42,10 @@ <When Condition="$(OS) == 'Windows_NT'"> <PropertyGroup> <!-- default paths --> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GalaxyClient\Games\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GOG Galaxy\Games\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\Steam\steamapps\common\Stardew Valley</GamePath> + <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath> <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> @@ -97,7 +101,8 @@ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> </Reference> <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces"> - <HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> + <HintPath>$(GamePath)\smapi-internal\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> + <HintPath Condition="!Exists('$(GamePath)\smapi-internal')">$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> <Private>false</Private> <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> </Reference> @@ -136,7 +141,8 @@ <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> </Reference> <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces"> - <HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> + <HintPath>$(GamePath)\smapi-internal\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> + <HintPath Condition="!Exists('$(GamePath)\smapi-internal')">$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> <Private>false</Private> <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> </Reference> diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index 3d6f2598..21693828 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,7 +2,7 @@ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <metadata> <id>Pathoschild.Stardew.ModBuildConfig</id> - <version>2.1.0</version> + <version>2.2</version> <title>Build package for SMAPI mods</title> <authors>Pathoschild</authors> <owners>Pathoschild</owners> @@ -12,13 +12,10 @@ <iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl> <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For Stardew Valley 1.3 or later.</description> <releaseNotes> - 2.1: - - Added support for Stardew Valley 1.3. - - Added support for non-mod projects. - - Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3. - - Added option to ignore files by regex pattern. - - Added reference to new SMAPI DLL. - - Fixed some game paths not detected by NuGet package. + 2.2: + - Added support for SMAPI 2.8+ (still compatible with earlier versions). + - Added default game paths for 32-bit Windows. + - Fixed valid manifests marked invalid in some cases. </releaseNotes> </metadata> </package> diff --git a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj index 50b7b87f..1137bb11 100644 --- a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj @@ -36,10 +36,6 @@ <PlatformTarget>x86</PlatformTarget> </PropertyGroup> <ItemGroup> - <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> - <Private>False</Private> - </Reference> <Reference Include="System" /> <Reference Include="System.Xml" /> </ItemGroup> @@ -89,7 +85,6 @@ <None Include="manifest.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> - <None Include="packages.config" /> </ItemGroup> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index a6c5cd88..3b00aa83 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "2.7.0", + "Version": "2.8.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "2.7.0" + "MinimumApiVersion": "2.8.1" } diff --git a/src/SMAPI.Mods.ConsoleCommands/packages.config b/src/SMAPI.Mods.ConsoleCommands/packages.config deleted file mode 100644 index c8b3ae63..00000000 --- a/src/SMAPI.Mods.ConsoleCommands/packages.config +++ /dev/null @@ -1,4 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> -</packages>
\ No newline at end of file diff --git a/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs b/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs deleted file mode 100644 index c9dcb216..00000000 --- a/src/SMAPI.Mods.SaveBackup/Framework/ModConfig.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace StardewModdingAPI.Mods.SaveBackup.Framework -{ - /// <summary>The mod configuration.</summary> - internal class ModConfig - { - /// <summary>The number of backups to keep.</summary> - public int BackupsToKeep { get; set; } = 10; - } -} diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index 78578c3c..4d56789a 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -4,7 +4,6 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; -using StardewModdingAPI.Mods.SaveBackup.Framework; using StardewValley; namespace StardewModdingAPI.Mods.SaveBackup @@ -15,6 +14,12 @@ namespace StardewModdingAPI.Mods.SaveBackup /********* ** Properties *********/ + /// <summary>The number of backups to keep.</summary> + private readonly int BackupsToKeep = 10; + + /// <summary>The absolute path to the folder in which to store save backups.</summary> + private readonly string BackupFolder = Path.Combine(Constants.ExecutionPath, "save-backups"); + /// <summary>The name of the save archive to create.</summary> private readonly string FileName = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}.zip"; @@ -28,15 +33,13 @@ namespace StardewModdingAPI.Mods.SaveBackup { try { - ModConfig config = this.Helper.ReadConfig<ModConfig>(); - // init backup folder - DirectoryInfo backupFolder = new DirectoryInfo(Path.Combine(this.Helper.DirectoryPath, "backups")); + DirectoryInfo backupFolder = new DirectoryInfo(this.BackupFolder); backupFolder.Create(); // back up saves this.CreateBackup(backupFolder); - this.PruneBackups(backupFolder, config.BackupsToKeep); + this.PruneBackups(backupFolder, this.BackupsToKeep); } catch (Exception ex) { diff --git a/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj index 0ccbcc6c..fafa4d25 100644 --- a/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj @@ -1,4 +1,4 @@ -<?xml version="1.0" encoding="utf-8"?> +<?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> @@ -36,7 +36,6 @@ <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> - <Compile Include="Framework\ModConfig.cs" /> <Compile Include="ModEntry.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> </ItemGroup> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index e973b449..f0c67056 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "2.7.0", + "Version": "2.8.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "2.7.0" + "MinimumApiVersion": "2.8.1" } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index a38621f8..4a1f04c6 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -145,7 +145,7 @@ namespace StardewModdingAPI.Tests.Core this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields { Status = ModStatus.AssumeBroken, - AlternativeUrl = "http://example.org" + AlternativeUrl = "https://example.org" }); // act @@ -513,6 +513,7 @@ namespace StardewModdingAPI.Tests.Core mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); mod.Setup(p => p.Manifest).Returns(manifest); + mod.Setup(p => p.HasID(It.IsAny<string>())).Returns((string id) => manifest.UniqueID == id); if (allowStatusChange) { mod diff --git a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj index b2d98d23..4ec1a3de 100644 --- a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj +++ b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj @@ -1,6 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <Import Project="..\packages\NUnit.3.10.1\build\NUnit.props" Condition="Exists('..\packages\NUnit.3.10.1\build\NUnit.props')" /> <Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" /> <PropertyGroup> <Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration> @@ -31,29 +30,17 @@ <WarningLevel>4</WarningLevel> </PropertyGroup> <ItemGroup> - <Reference Include="Castle.Core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=407dd0808d44fbdc, processorArchitecture=MSIL"> - <HintPath>..\packages\Castle.Core.4.3.1\lib\net45\Castle.Core.dll</HintPath> - </Reference> - <Reference Include="Moq, Version=4.8.0.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL"> - <HintPath>..\packages\Moq.4.8.3\lib\net45\Moq.dll</HintPath> - </Reference> - <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> - </Reference> - <Reference Include="nunit.framework, Version=3.10.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL"> - <HintPath>..\packages\NUnit.3.10.1\lib\net45\nunit.framework.dll</HintPath> - </Reference> + <PackageReference Include="Castle.Core" Version="4.3.1" /> + <PackageReference Include="Moq" Version="4.10.0" /> + <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + <PackageReference Include="NUnit" Version="3.11.0" /> + <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.5.2" /> + <PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.1" /> + <PackageReference Include="System.ValueTuple" Version="4.5.0" /> + </ItemGroup> + <ItemGroup> <Reference Include="System" /> <Reference Include="System.Configuration" /> - <Reference Include="System.Runtime.CompilerServices.Unsafe, Version=4.0.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL"> - <HintPath>..\packages\System.Runtime.CompilerServices.Unsafe.4.5.1\lib\netstandard1.0\System.Runtime.CompilerServices.Unsafe.dll</HintPath> - </Reference> - <Reference Include="System.Threading.Tasks.Extensions, Version=4.2.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL"> - <HintPath>..\packages\System.Threading.Tasks.Extensions.4.5.1\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll</HintPath> - </Reference> - <Reference Include="System.ValueTuple, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL"> - <HintPath>..\packages\System.ValueTuple.4.5.0\lib\netstandard1.0\System.ValueTuple.dll</HintPath> - </Reference> </ItemGroup> <ItemGroup> <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> @@ -69,7 +56,6 @@ </ItemGroup> <ItemGroup> <None Include="app.config" /> - <None Include="packages.config" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj"> @@ -90,10 +76,4 @@ </ItemGroup> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\packages\NUnit.3.10.1\build\NUnit.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\NUnit.3.10.1\build\NUnit.props'))" /> - </Target> </Project>
\ No newline at end of file diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 35d74b60..1782308b 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -127,6 +127,7 @@ namespace StardewModdingAPI.Tests.Utilities [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)] + [TestCase("1.0-unofficial.1", "1.0-beta.1", ExpectedResult = -1)] // special case: 'unofficial' has lower priority than official releases // more than [TestCase("0.5.8", "0.5.7", ExpectedResult = 1)] diff --git a/src/SMAPI.Tests/packages.config b/src/SMAPI.Tests/packages.config deleted file mode 100644 index 7c3ec9f1..00000000 --- a/src/SMAPI.Tests/packages.config +++ /dev/null @@ -1,10 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="Castle.Core" version="4.3.1" targetFramework="net45" /> - <package id="Moq" version="4.8.3" targetFramework="net45" /> - <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> - <package id="NUnit" version="3.10.1" targetFramework="net45" /> - <package id="System.Runtime.CompilerServices.Unsafe" version="4.5.1" targetFramework="net45" /> - <package id="System.Threading.Tasks.Extensions" version="4.5.1" targetFramework="net45" /> - <package id="System.ValueTuple" version="4.5.0" targetFramework="net45" /> -</packages>
\ No newline at end of file diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 8c4a0332..d7be664d 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -67,15 +67,22 @@ namespace StardewModdingAPI.Web.Controllers IndexVersionModel stableVersionModel = stableVersion != null ? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl) : new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong) - IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta + IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.BetaEnabled ? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl) : null; // render view - var model = new IndexModel(stableVersionModel, betaVersionModel); + var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb); return this.View(model); } + /// <summary>Display the index page.</summary> + [HttpGet("/privacy")] + public ViewResult Privacy() + { + return this.View(); + } + /********* ** Private methods diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 18d55665..f0835592 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -12,6 +12,7 @@ using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.Nexus; @@ -29,7 +30,7 @@ namespace StardewModdingAPI.Web.Controllers ** Properties *********/ /// <summary>The mod repositories which provide mod metadata.</summary> - private readonly IDictionary<string, IModRepository> Repositories; + private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; /// <summary>The cache in which to store mod metadata.</summary> private readonly IMemoryCache Cache; @@ -46,8 +47,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; - /// <summary>The web URL for the wiki compatibility list.</summary> - private readonly string WikiCompatibilityPageUrl; + /// <summary>The web URL for the compatibility list.</summary> + private readonly string CompatibilityPageUrl; /********* @@ -64,7 +65,7 @@ namespace StardewModdingAPI.Web.Controllers { this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; - this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl; + this.CompatibilityPageUrl = config.CompatibilityPageUrl; this.Cache = cache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; @@ -73,11 +74,11 @@ namespace StardewModdingAPI.Web.Controllers this.Repositories = new IModRepository[] { - new ChucklefishRepository(config.ChucklefishKey, chucklefish), - new GitHubRepository(config.GitHubKey, github), - new NexusRepository(config.NexusKey, nexus) + new ChucklefishRepository(chucklefish), + new GitHubRepository(github), + new NexusRepository(nexus) } - .ToDictionary(p => p.VendorKey, StringComparer.CurrentCultureIgnoreCase); + .ToDictionary(p => p.VendorKey); } /// <summary>Fetch version metadata for the given mods.</summary> @@ -89,7 +90,7 @@ namespace StardewModdingAPI.Web.Controllers return new ModEntryModel[0]; // fetch wiki data - WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync(); + WikiModEntry[] wikiData = await this.GetWikiDataAsync(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { @@ -113,17 +114,12 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="wikiData">The wiki data.</param> /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> /// <returns>Returns the mod data if found, else <c>null</c>.</returns> - private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata) + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata) { - // resolve update keys - var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase); + // crossreference data ModDataRecord record = this.ModDatabase.Get(search.ID); - if (record?.Fields != null) - { - string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; - if (!string.IsNullOrWhiteSpace(defaultUpdateKey)) - updateKeys.Add(defaultUpdateKey); - } + WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); + string[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); // get latest versions ModEntryModel result = new ModEntryModel { ID = search.ID }; @@ -166,9 +162,25 @@ namespace StardewModdingAPI.Web.Controllers } // get unofficial version - WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); - if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version)) - result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl); + if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Optional?.Version)) + result.Unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); + + // get unofficial version for beta + if (wikiEntry?.HasBetaInfo == true) + { + result.HasBetaInfo = true; + if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) + { + if (wikiEntry.BetaCompatibility.UnofficialVersion != null) + { + result.UnofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Optional?.Version)) + ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}") + : null; + } + else + result.UnofficialForBeta = result.Unofficial; + } + } // fallback to preview if latest is invalid if (result.Main == null && result.Optional != null) @@ -195,28 +207,6 @@ namespace StardewModdingAPI.Web.Controllers return result; } - /// <summary>Parse a namespaced mod ID.</summary> - /// <param name="raw">The raw mod ID to parse.</param> - /// <param name="vendorKey">The parsed vendor key.</param> - /// <param name="modID">The parsed mod ID.</param> - /// <returns>Returns whether the value could be parsed.</returns> - private bool TryParseModKey(string raw, out string vendorKey, out string modID) - { - // split parts - string[] parts = raw?.Split(':'); - if (parts == null || parts.Length != 2) - { - vendorKey = null; - modID = null; - return false; - } - - // parse - vendorKey = parts[0].Trim(); - modID = parts[1].Trim(); - return true; - } - /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary> /// <param name="current">The current version.</param> /// <param name="other">The other version.</param> @@ -226,21 +216,21 @@ namespace StardewModdingAPI.Web.Controllers } /// <summary>Get mod data from the wiki compatibility list.</summary> - private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync() + private async Task<WikiModEntry[]> GetWikiDataAsync() { ModToolkit toolkit = new ModToolkit(); - return await this.Cache.GetOrCreateAsync($"_wiki", async entry => + return await this.Cache.GetOrCreateAsync("_wiki", async entry => { try { - WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync(); + WikiModEntry[] entries = (await toolkit.GetWikiCompatibilityListAsync()).Mods; entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); return entries; } catch { entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); - return new WikiCompatibilityEntry[0]; + return new WikiModEntry[0]; } }); } @@ -250,18 +240,19 @@ namespace StardewModdingAPI.Web.Controllers private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) { // parse update key - if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID)) + UpdateKey parsed = UpdateKey.Parse(updateKey); + if (!parsed.LooksValid) return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); // get matching repository - if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository)) - return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + if (!this.Repositories.TryGetValue(parsed.Repository, out IModRepository repository)) + return new ModInfoModel($"There's no mod site with key '{parsed.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); // fetch mod info - return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry => + return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{parsed.ID}".ToLower(), async entry => { - ModInfoModel result = await repository.GetModInfoAsync(modID); - if (result.Error != null) + ModInfoModel result = await repository.GetModInfoAsync(parsed.ID); + if (result.Error == null) { if (result.Version == null) result.Error = $"The update key '{updateKey}' matches a mod with no version number."; @@ -273,11 +264,42 @@ namespace StardewModdingAPI.Web.Controllers }); } - /// <summary>Get the requested API version.</summary> - private ISemanticVersion GetApiVersion() + /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> + /// <param name="specifiedKeys">The specified update keys.</param> + /// <param name="record">The mod's entry in SMAPI's internal database.</param> + /// <param name="entry">The mod's entry in the wiki list.</param> + public IEnumerable<string> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { - string actualVersion = (string)this.RouteData.Values["version"]; - return new SemanticVersion(actualVersion); + IEnumerable<string> GetRaw() + { + // specified update keys + if (specifiedKeys != null) + { + foreach (string key in specifiedKeys) + yield return key?.Trim(); + } + + // default update key + string defaultKey = record?.GetDefaultUpdateKey(); + if (defaultKey != null) + yield return defaultKey; + + // wiki metadata + if (entry != null) + { + if (entry.NexusID.HasValue) + yield return $"Nexus:{entry.NexusID}"; + if (entry.ChucklefishID.HasValue) + yield return $"Chucklefish:{entry.ChucklefishID}"; + } + } + + HashSet<string> seen = new HashSet<string>(StringComparer.InvariantCulture); + foreach (string key in GetRaw()) + { + if (!string.IsNullOrWhiteSpace(key) && seen.Add(key)) + yield return key; + } } } } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs new file mode 100644 index 00000000..57aa9da9 --- /dev/null +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -0,0 +1,74 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.ViewModels; + +namespace StardewModdingAPI.Web.Controllers +{ + /// <summary>Provides user-friendly info about SMAPI mods.</summary> + internal class ModsController : Controller + { + /********* + ** Properties + *********/ + /// <summary>The cache in which to store mod metadata.</summary> + private readonly IMemoryCache Cache; + + /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> + private readonly int SuccessCacheMinutes; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="cache">The cache in which to store mod metadata.</param> + /// <param name="configProvider">The config settings for mod update checks.</param> + public ModsController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider) + { + ModUpdateCheckConfig config = configProvider.Value; + + this.Cache = cache; + this.SuccessCacheMinutes = config.SuccessCacheMinutes; + } + + /// <summary>Display information for all mods.</summary> + [HttpGet] + [Route("mods")] + public async Task<ViewResult> Index() + { + return this.View("Index", await this.FetchDataAsync()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Asynchronously fetch mod metadata from the wiki.</summary> + public async Task<ModListModel> FetchDataAsync() + { + return await this.Cache.GetOrCreateAsync($"{nameof(ModsController)}_mod_list", async entry => + { + WikiModList data = await new ModToolkit().GetWikiCompatibilityListAsync(); + ModListModel model = new ModListModel( + stableVersion: data.StableVersion, + betaVersion: data.BetaVersion, + mods: data + .Mods + .Select(mod => new ModModel(mod)) + .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")) // ignore case, spaces, and special characters when sorting + ); + + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); + return model; + }); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs index 4ecf2f76..f4909155 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -1,5 +1,4 @@ using Newtonsoft.Json; -using StardewModdingAPI.Toolkit; namespace StardewModdingAPI.Web.Framework.Clients.Nexus { diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index ce4f3cb5..bde566c0 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -16,16 +16,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <remarks>Derived from SMAPI's SemanticVersion implementation.</remarks> public string SemanticVersionRegex { get; set; } - /// <summary>The repository key for the Chucklefish mod site.</summary> - public string ChucklefishKey { get; set; } - - /// <summary>The repository key for Nexus Mods.</summary> - public string GitHubKey { get; set; } - - /// <summary>The repository key for Nexus Mods.</summary> - public string NexusKey { get; set; } - /// <summary>The web URL for the wiki compatibility list.</summary> - public string WikiCompatibilityPageUrl { get; set; } + public string CompatibilityPageUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index 3d428015..d89a4260 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -12,7 +12,13 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The root URL for the log parser.</summary> public string LogParserUrl { get; set; } + /// <summary>The root URL for the mod list.</summary> + public string ModListUrl { get; set; } + /// <summary>Whether to show SMAPI beta versions on the main page, if any.</summary> - public bool EnableSmapiBeta { get; set; } + public bool BetaEnabled { get; set; } + + /// <summary>A short sentence shown under the beta download button, if any.</summary> + public string BetaBlurb { get; set; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 013c6c47..f9b5ba76 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -70,6 +70,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // parse log messages LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "" }; + LogModInfo gameMod = new LogModInfo { Name = "game", Author = "", Description = "" }; IDictionary<string, LogModInfo> mods = new Dictionary<string, LogModInfo>(); bool inModList = false; bool inContentPackList = false; @@ -78,10 +79,23 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // collect stats if (message.Level == LogLevel.Error) { - if (message.Mod == "SMAPI") - smapiMod.Errors++; - else if (mods.ContainsKey(message.Mod)) - mods[message.Mod].Errors++; + switch (message.Mod) + { + case "SMAPI": + smapiMod.Errors++; + break; + + case "game": + gameMod.Errors++; + break; + + default: + { + if (mods.ContainsKey(message.Mod)) + mods[message.Mod].Errors++; + break; + } + } } // collect SMAPI metadata @@ -151,7 +165,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // finalise log - log.Mods = new[] { smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray(); + gameMod.Version = log.GameVersion; + log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray(); return log; } catch (LogParseException ex) diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs index 4a4a40cd..94256005 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs @@ -1,6 +1,6 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.ModRepositories { @@ -10,7 +10,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Accessors *********/ /// <summary>The unique key for this vendor.</summary> - public string VendorKey { get; } + public ModRepositoryKey VendorKey { get; } /********* @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories *********/ /// <summary>Construct an instance.</summary> /// <param name="vendorKey">The unique key for this vendor.</param> - protected RepositoryBase(string vendorKey) + protected RepositoryBase(ModRepositoryKey vendorKey) { this.VendorKey = vendorKey; } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index e6074a60..6e2a8814 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; namespace StardewModdingAPI.Web.Framework.ModRepositories @@ -19,10 +19,9 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="vendorKey">The unique key for this vendor.</param> /// <param name="client">The underlying HTTP client.</param> - public ChucklefishRepository(string vendorKey, IChucklefishClient client) - : base(vendorKey) + public ChucklefishRepository(IChucklefishClient client) + : base(ModRepositoryKey.Chucklefish) { this.Client = client; } diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 1d7e4fff..7ff22d0e 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.GitHub; namespace StardewModdingAPI.Web.Framework.ModRepositories @@ -19,10 +19,9 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="vendorKey">The unique key for this vendor.</param> /// <param name="client">The underlying GitHub API client.</param> - public GitHubRepository(string vendorKey, IGitHubClient client) - : base(vendorKey) + public GitHubRepository(IGitHubClient client) + : base(ModRepositoryKey.GitHub) { this.Client = client; } diff --git a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs index 09c59a86..68f754ae 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/IModRepository.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework.ModRepositories { @@ -10,7 +11,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Accessors *********/ /// <summary>The unique key for this vendor.</summary> - string VendorKey { get; } + ModRepositoryKey VendorKey { get; } /********* diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index 4afcda10..1e242c60 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -1,6 +1,6 @@ using System; using System.Threading.Tasks; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.UpdateData; using StardewModdingAPI.Web.Framework.Clients.Nexus; namespace StardewModdingAPI.Web.Framework.ModRepositories @@ -19,10 +19,9 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="vendorKey">The unique key for this vendor.</param> /// <param name="client">The underlying Nexus Mods API client.</param> - public NexusRepository(string vendorKey, INexusClient client) - : base(vendorKey) + public NexusRepository(INexusClient client) + : base(ModRepositoryKey.Nexus) { this.Client = client; } diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj index 6761c7ad..9d1990d9 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj @@ -10,10 +10,10 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.4" /> - <PackageReference Include="Markdig" Version="0.15.0" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.1.1" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.1" /> + <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> + <PackageReference Include="Markdig" Version="0.15.4" /> + <PackageReference Include="Microsoft.AspNetCore" Version="2.1.4" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" /> <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" /> <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" /> @@ -27,6 +27,12 @@ <ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> + <Content Update="Views\Index\Privacy.cshtml"> + <Pack>$(IncludeRazorContentInPack)</Pack> + </Content> + <Content Update="Views\Mods\Index.cshtml"> + <Pack>$(IncludeRazorContentInPack)</Pack> + </Content> <Content Update="wwwroot\StardewModdingAPI.metadata.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index bf3ec9a1..60a16053 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -147,14 +147,14 @@ namespace StardewModdingAPI.Web redirects.Add(new ConditionalRewriteSubdomainRule( shouldRewrite: req => req.Host.Host != "localhost" - && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.")) + && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) && !req.Path.StartsWithSegments("/content") && !req.Path.StartsWithSegments("/favicon.ico") )); // shortcut redirects redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1")); - redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility")); + redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://mods.smapi.io")); redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs index 4268c878..82c4e06f 100644 --- a/src/SMAPI.Web/ViewModels/IndexModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The latest prerelease SMAPI version (if newer than <see cref="StableVersion"/>).</summary> public IndexVersionModel BetaVersion { get; set; } + /// <summary>A short sentence shown under the beta download button, if any.</summary> + public string BetaBlurb { get; set; } + /********* ** Public methods @@ -22,10 +25,12 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The latest stable SMAPI version.</param> /// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param> - internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion) + /// <param name="betaBlurb">A short sentence shown under the beta download button, if any.</param> + internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; + this.BetaBlurb = betaBlurb; } } } diff --git a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs new file mode 100644 index 00000000..85bf1e46 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs @@ -0,0 +1,40 @@ +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>Metadata about a mod's compatibility with the latest versions of SMAPI and Stardew Valley.</summary> + public class ModCompatibilityModel + { + /********* + ** Accessors + *********/ + /// <summary>The compatibility status, as a string like <c>"Broken"</c>.</summary> + public string Status { get; set; } + + /// <summary>The human-readable summary, as an HTML block.</summary> + public string Summary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string BrokeIn { get; set; } + + /// <summary>A link to the unofficial version which fixes compatibility, if any.</summary> + public ModLinkModel UnofficialVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="info">The mod metadata.</param> + public ModCompatibilityModel(WikiCompatibilityInfo info) + { + this.Status = info.Status.ToString(); + this.Status = this.Status.Substring(0, 1).ToLower() + this.Status.Substring(1); + + this.Summary = info.Summary; + this.BrokeIn = info.BrokeIn; + if (info.UnofficialVersion != null) + this.UnofficialVersion = new ModLinkModel(info.UnofficialUrl, info.UnofficialVersion.ToString()); + } + } +} diff --git a/src/SMAPI.Web/ViewModels/ModLinkModel.cs b/src/SMAPI.Web/ViewModels/ModLinkModel.cs new file mode 100644 index 00000000..97dd215c --- /dev/null +++ b/src/SMAPI.Web/ViewModels/ModLinkModel.cs @@ -0,0 +1,28 @@ +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>Metadata about a link.</summary> + public class ModLinkModel + { + /********* + ** Accessors + *********/ + /// <summary>The URL of the linked page.</summary> + public string Url { get; set; } + + /// <summary>The suggested link text.</summary> + public string Text { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="url">The URL of the linked page.</param> + /// <param name="text">The suggested link text.</param> + public ModLinkModel(string url, string text) + { + this.Url = url; + this.Text = text; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs new file mode 100644 index 00000000..3b87d393 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>Metadata for the mod list page.</summary> + public class ModListModel + { + /********* + ** Accessors + *********/ + /// <summary>The current stable version of the game.</summary> + public string StableVersion { get; set; } + + /// <summary>The current beta version of the game (if any).</summary> + public string BetaVersion { get; set; } + + /// <summary>The mods to display.</summary> + public ModModel[] Mods { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="stableVersion">The current stable version of the game.</param> + /// <param name="betaVersion">The current beta version of the game (if any).</param> + /// <param name="mods">The mods to display.</param> + public ModListModel(string stableVersion, string betaVersion, IEnumerable<ModModel> mods) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.Mods = mods.ToArray(); + } + } +} diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs new file mode 100644 index 00000000..0e7d2076 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>Metadata about a mod.</summary> + public class ModModel + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The mod's alternative names, if any.</summary> + public string AlternateNames { get; set; } + + /// <summary>The mod author's name.</summary> + public string Author { get; set; } + + /// <summary>The mod author's alternative names, if any.</summary> + public string AlternateAuthors { get; set; } + + /// <summary>The URL to the mod's source code, if any.</summary> + public string SourceUrl { get; set; } + + /// <summary>The compatibility status for the stable version of the game.</summary> + public ModCompatibilityModel Compatibility { get; set; } + + /// <summary>The compatibility status for the beta version of the game.</summary> + public ModCompatibilityModel BetaCompatibility { get; set; } + + /// <summary>Links to the available mod pages.</summary> + public ModLinkModel[] ModPages { get; set; } + + /// <summary>The human-readable warnings for players about this mod.</summary> + public string[] Warnings { get; set; } + + /// <summary>A unique identifier for the mod that can be used in an anchor URL.</summary> + public string Slug { get; set; } + + /// <summary>The sites where the mod can be downloaded.</summary> + public string[] ModPageSites => this.ModPages.Select(p => p.Text).ToArray(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="entry">The mod metadata.</param> + public ModModel(WikiModEntry entry) + { + // basic info + this.Name = entry.Name.FirstOrDefault(); + this.AlternateNames = string.Join(", ", entry.Name.Skip(1).ToArray()); + this.Author = entry.Author.FirstOrDefault(); + this.AlternateAuthors = string.Join(", ", entry.Author.Skip(1).ToArray()); + this.SourceUrl = this.GetSourceUrl(entry); + this.Compatibility = new ModCompatibilityModel(entry.Compatibility); + this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; + this.ModPages = this.GetModPageUrls(entry).ToArray(); + this.Warnings = entry.Warnings; + this.Slug = entry.Anchor; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the web URL for the mod's source code repository, if any.</summary> + /// <param name="entry">The mod metadata.</param> + private string GetSourceUrl(WikiModEntry entry) + { + if (!string.IsNullOrWhiteSpace(entry.GitHubRepo)) + return $"https://github.com/{entry.GitHubRepo}"; + if (!string.IsNullOrWhiteSpace(entry.CustomSourceUrl)) + return entry.CustomSourceUrl; + return null; + } + + /// <summary>Get the web URLs for the mod pages, if any.</summary> + /// <param name="entry">The mod metadata.</param> + private IEnumerable<ModLinkModel> GetModPageUrls(WikiModEntry entry) + { + bool anyFound = false; + + // normal mod pages + if (entry.NexusID.HasValue) + { + anyFound = true; + yield return new ModLinkModel($"https://www.nexusmods.com/stardewvalley/mods/{entry.NexusID}", "Nexus"); + } + if (entry.ChucklefishID.HasValue) + { + anyFound = true; + yield return new ModLinkModel($"https://community.playstarbound.com/resources/{entry.ChucklefishID}", "Chucklefish"); + } + + // fallback + if (!anyFound && !string.IsNullOrWhiteSpace(entry.CustomUrl)) + { + anyFound = true; + yield return new ModLinkModel(entry.CustomUrl, "custom"); + } + if (!anyFound && !string.IsNullOrWhiteSpace(entry.GitHubRepo)) + yield return new ModLinkModel($"https://github.com/{entry.GitHubRepo}/releases", "GitHub"); + } + } +} diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 361d01de..01874f50 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -1,10 +1,13 @@ +@using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework.ConfigModels +@inject IOptions<SiteConfig> SiteConfig +@model StardewModdingAPI.Web.ViewModels.IndexModel @{ ViewData["Title"] = "SMAPI"; } -@model StardewModdingAPI.Web.ViewModels.IndexModel @section Head { <link rel="stylesheet" href="~/Content/css/index.css?r=20180615" /> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> <script src="~/Content/js/index.js?r=20180615"></script> } @@ -16,7 +19,7 @@ <div id="call-to-action"> <div class="cta-dropdown"> - <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br/> + <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br /> <div class="dropdown-content"> <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a> <a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a> @@ -26,20 +29,29 @@ @if (Model.BetaVersion != null) { <div class="cta-dropdown secondary-cta-dropdown"> - <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta download">Download SMAPI @Model.BetaVersion.Version<br/><small>for Stardew Valley 1.3 beta</small></a><br/> + <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta download"> + Download SMAPI @Model.BetaVersion.Version + @if (!string.IsNullOrWhiteSpace(Model.BetaBlurb)) + { + <br /><small>@Model.BetaBlurb</small> + } + </a><br /> <div class="dropdown-content"> <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a> <a href="@Model.BetaVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a> </div> </div><br /> } - <a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a><br /> + <div><a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a></div> + <div class="sublinks"> + <a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@(new UriBuilder(SiteConfig.Value.RootUrl) { Path = "privacy" }.Uri)">privacy</a> + </div> <img id="pufferchick" src="Content/images/pufferchick.png" /> </div> <h2 id="help">Get help</h2> <ul> - <li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li> + <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility list</a></li> <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li> </ul> @@ -49,7 +61,7 @@ <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> } else { @@ -58,13 +70,13 @@ else <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> <h3>SMAPI @Model.BetaVersion.Version?</h3> <div class="github-description"> @Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description)) </div> - <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@SiteConfig.Value.ModListUrl">mod compatibility list</a> for more info.</p> } <h2 id="donate">Donate to support SMAPI ♥</h2> @@ -87,13 +99,13 @@ else <p> Special thanks to + AbroadKew, acerbicon, <a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>, cheesysteak, hawkfalcon, jwdred, - KNakamura, - Kono Tyran, + <a href="https://www.nexusmods.com/users/12252523">Karmylla</a>, Pucklynn, Robby LaFarge, and a few anonymous users for their ongoing support; you're awesome! 🏅 diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml new file mode 100644 index 00000000..ca99eef6 --- /dev/null +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -0,0 +1,43 @@ +@using Microsoft.Extensions.Options +@using StardewModdingAPI.Web.Framework.ConfigModels +@inject IOptions<SiteConfig> SiteConfig +@{ + ViewData["Title"] = "SMAPI privacy notes"; +} +@section Head { + <link rel="stylesheet" href="~/Content/css/privacy.css" /> +} + +← <a href="@SiteConfig.Value.RootUrl">back to SMAPI page</a> + +<p>SMAPI is an <a href="https://github.com/Pathoschild/SMAPI">open-source</a> and non-profit project. Your privacy is important, so this page explains what information SMAPI uses and transmits. <strong>This page is informational only, it's not a legal document.</strong></p> + +<h2>Principles</h2> +<ol> + <li>SMAPI collects the minimum information needed to enable its features (see below).</li> + <li>SMAPI does not collect telemetry, analytics, etc.</li> + <li>SMAPI will never sell your information.</li> +</ol> + +<h2>Data collected and transmitted</h2> +<h3 id="web-logging">Web logging</h3> +<p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p> + +<h3>Update checks</h3> +<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your SMAPI and mod versions to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> + +<p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p> +<ol> + <li><a href="https://stardewvalleywiki.com/Modding:Game_folder">find your game folder</a>;</li> + <li>open the <code>smapi-internal/StardewModdingAPI.config.json</code> file in a text editor;</li> + <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li> +</ol> + +<h3>Log parser</h3> +<p>The <a href="https://log.smapi.io/">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p> + +<h3>Multiplayer sync</h3> +<p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p> + +<h3>Custom mods</h3> +<p><strong>Mods may collect and transmit any information. Mods (except those provided as part of the SMAPI download) are not covered by this page. Install third-party mods at your own risk.</strong></p> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index e735e8f3..58830d64 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -18,8 +18,8 @@ <meta name="robots" content="noindex" /> } <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180627" /> - <script src="https://cdn.jsdelivr.net/npm/vue"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> <script src="~/Content/js/log-parser.js?r=20180627"></script> <script> $(function() { @@ -84,7 +84,7 @@ else if (Model.ParsedLog?.IsValid == true) On Mac: <ol> <li>Open the Finder app.</li> - <li>Click <em>Go</em> at the top, then <em>Enter Location</em>.</li> + <li>Click <em>Go</em> at the top, then <em>Go to Folder</em>.</li> <li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li> <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> @@ -145,14 +145,14 @@ else if (Model.ParsedLog?.IsValid == true) @if (!Model.ShowRaw) { <span class="notice txt"><i>click any mod to filter</i></span> - <span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span> - <span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span> + <span class="notice btn txt" v-on:click="showAllMods" v-bind:class="{ invisible: !anyModsHidden }">show all</span> + <span class="notice btn txt" v-on:click="hideAllMods" v-bind:class="{ invisible: !anyModsShown || !anyModsHidden }">hide all</span> } </caption> @foreach (var mod in Model.ParsedLog.Mods.Where(p => p.ContentPackFor == null)) { <tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }"> - <td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-show="anyModsHidden" /></td> + <td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-bind:class="{ invisible: !anyModsHidden }" /></td> <td v-pre> <strong>@mod.Name</strong> @mod.Version @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) @@ -165,7 +165,18 @@ else if (Model.ParsedLog?.IsValid == true) </div> } </td> - <td v-pre>@mod.Author</td> + <td v-pre> + @mod.Author + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + { + <div class="content-packs"> + @foreach (var contentPack in contentPackList) + { + <text>+ @contentPack.Author</text><br /> + } + </div> + } + </td> @if (mod.Errors == 0) { <td v-pre class="color-green">no errors</td> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml new file mode 100644 index 00000000..372d6706 --- /dev/null +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -0,0 +1,94 @@ +@using Newtonsoft.Json +@model StardewModdingAPI.Web.ViewModels.ModListModel +@{ + ViewData["Title"] = "SMAPI mod compatibility"; +} +@section Head { + <link rel="stylesheet" href="~/Content/css/mods.css?r=20181109" /> + <script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/tablesorter@2.31.0/dist/js/jquery.tablesorter.combined.min.js" crossorigin="anonymous"></script> + <script src="~/Content/js/mods.js?r=20181109"></script> + <script> + $(function() { + var data = @Json.Serialize(Model.Mods, new JsonSerializerSettings { Formatting = Formatting.None }); + smapi.modList(data); + }); + </script> +} + +<div id="intro"> + <p>This page lists all known SMAPI mods, whether they're compatible with the latest versions of Stardew Valley and SMAPI, and how to fix broken mods if possible. The list is updated every few days. (You can help <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">edit this list</a>!)</p> + + <p>If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#Ask_for_help">ask for help</a>.</p> + + @if (Model.BetaVersion != null) + { + <p id="beta-blurb"><strong>Note:</strong> "SDV beta only" means Stardew Valley @Model.BetaVersion-beta; if you didn't opt in to the beta, you have the stable version and can ignore that line. If a mod doesn't have a "SDV beta only" line, the compatibility applies to both versions of the game.</p> + } +</div> + +<div id="app"> + <div id="options"> + <div> + <label for="search-box">Search: </label> + <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" /> + </div> + <div id="filter-area"> + <input type="checkbox" id="show-advanced" v-model="showAdvanced" /> + <label for="show-advanced">show detailed options</label> + <div id="filters" v-show="showAdvanced"> + <div v-for="(filterGroup, key) in filters"> + {{key}}: <span v-for="filter in filterGroup" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span> + </div> + </div> + </div> + </div> + <div id="mod-count" v-show="showAdvanced">{{visibleCount}} mods shown.</div> + <table class="wikitable" id="mod-list"> + <thead> + <tr> + <th>mod name</th> + <th>links</th> + <th>author</th> + <th>compatibility</th> + <th v-show="showAdvanced">broke in</th> + <th v-show="showAdvanced">code</th> + <th> </th> + </tr> + </thead> + <tbody> + <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.BetaCompatibility != null ? mod.BetaCompatibility.Status : mod.Compatibility.Status" v-show="mod.Visible"> + <td> + {{mod.Name}} + <small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small> + </td> + <td class="mod-page-links"> + <span v-for="(link, i) in mod.ModPages"> + <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} + </span> + </td> + <td> + {{mod.Author}} + <small class="mod-alt-authors" v-if="mod.AlternateAuthors">(aka {{mod.AlternateAuthors}})</small> + </td> + <td> + <div v-html="mod.Compatibility.Summary"></div> + <div v-if="mod.BetaCompatibility"> + <strong v-if="mod.BetaCompatibility">SDV beta only:</strong> + <span v-html="mod.BetaCompatibility.Summary"></span> + </div> + <div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div> + </td> + <td class="mod-broke-in" v-html="mod.BetaCompatibility ? mod.BetaCompatibility.BrokeIn : mod.Compatibility.BrokeIn" v-show="showAdvanced"></td> + <td v-show="showAdvanced"> + <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span> + <span v-else class="mod-closed-source">no source</span> + </td> + <td> + <small><a v-bind:href="'#' + mod.Slug">#</a></small> + </td> + </tr> + </tbody> + </table> +</div> diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 29da9100..4c602b29 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -16,6 +16,7 @@ <h4>SMAPI</h4> <ul> <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li> + <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li> <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li> <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li> </ul> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 67bb7748..db90a3de 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -19,8 +19,10 @@ "Site": { "RootUrl": "http://localhost:59482/", + "ModListUrl": "http://localhost:59482/mods/", "LogParserUrl": "http://localhost:59482/log/", - "EnableSmapiBeta": false + "BetaEnabled": false, + "BetaBlurb": null }, "ApiClients": { diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9e3270ae..aba8c448 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -16,8 +16,10 @@ "Site": { "RootUrl": null, // see top note + "ModListUrl": null, // see top note "LogParserUrl": null, // see top note - "EnableSmapiBeta": null // see top note + "BetaEnabled": null, // see top note + "BetaBlurb": null // see top note }, "ApiClients": { @@ -46,11 +48,6 @@ "SuccessCacheMinutes": 60, "ErrorCacheMinutes": 5, "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$", - - "ChucklefishKey": "Chucklefish", - "GitHubKey": "GitHub", - "NexusKey": "Nexus", - - "WikiCompatibilityPageUrl": "https://smapi.io/compat" + "CompatibilityPageUrl": "https://mods.smapi.io" } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/index.css b/src/SMAPI.Web/wwwroot/Content/css/index.css index 514e1a5c..979af4af 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/index.css +++ b/src/SMAPI.Web/wwwroot/Content/css/index.css @@ -93,6 +93,11 @@ h1 { display: block; } +.sublinks { + font-size: 0.9em; + margin-bottom: 1em; +} + /********* ** Subsections *********/ diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 1fcd1bff..2f3dd0a1 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -63,6 +63,10 @@ table#metadata, table#mods { box-shadow: 1px 1px 1px 1px #dddddd; } +.invisible { + visibility: hidden; +} + #mods { min-width: 400px; } diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css new file mode 100644 index 00000000..730bfc2e --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -0,0 +1,135 @@ +/********* +** Intro +*********/ +#content { + max-width: calc(100% - 2em); /* allow for wider table if room available */ +} + +#intro { + width: 50em; +} + +#beta-blurb { + margin-bottom: 2em; + padding: 1em; + border: 3px solid darkgreen; +} + +table.wikitable { + background-color:#f8f9fa; + color:#222; + border:1px solid #a2a9b1; + border-collapse:collapse +} + +table.wikitable > tr > th, +table.wikitable > tr > td, +table.wikitable > * > tr > th, +table.wikitable > * > tr > td { + border:1px solid #a2a9b1; + padding:0.2em 0.4em +} + +table.wikitable > tr > th, +table.wikitable > * > tr > th { + background-color:#eaecf0; +} + +table.wikitable > caption { + font-weight:bold +} + +#options { + margin-bottom: 1em; +} + +#options #filter-area { + opacity: 0.7; +} + +#options #filters { + margin-left: 2em; + padding-left: 0.5em; + border-left: 2px solid gray; +} + +#options #filters span { + padding: 2px; + margin: 2px; + display: inline-block; + border-radius: 3px; + color: #000; + border-color: #880000; + background-color: #fcc; + font-size: 0.9em; +} + +#options #filters span.active { + background: #cfc; +} + +#mod-count { + font-size: 0.8em; + opacity: 0.5; +} + +#mod-list { + font-size: 0.9em; +} + +#mod-list th.header { + background-repeat: no-repeat; + background-position: center right; + cursor: pointer; + background-image: url(data:image/gif;base64,R0lGODlhFQAJAIABAAAAAAAAACH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4wLWMwNjAgNjEuMTM0Nzc3LCAyMDEwLzAyLzEyLTE3OjMyOjAwICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjAxODAxMTc0MDcyMDY4MTE4OEM2REYyN0ExMDhBNDJFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjdCNTAyODcwMEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjdCNTAyODZGMEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzUgTWFjaW50b3NoIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDE4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDE4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4NDAsKCQgHBgUEAwIBAAAh+QQBAAABACwAAAAAFQAJAAACF4yPgMsJ2mJ4VDKKrd4GVz5lYPeMiVUAADs=); + padding-right: 1.5em; +} + +#mod-list th.headerSortUp { + background-image: url(data:image/gif;base64,R0lGODlhFQAEAIABAAAAAAAAACH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4wLWMwNjAgNjEuMTM0Nzc3LCAyMDEwLzAyLzEyLTE3OjMyOjAwICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjAzODAxMTc0MDcyMDY4MTE4OEM2REYyN0ExMDhBNDJFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjdCNTAyODc0MEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjdCNTAyODczMEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzUgTWFjaW50b3NoIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDM4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDM4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4NDAsKCQgHBgUEAwIBAAAh+QQBAAABACwAAAAAFQAEAAACDYwfoAvoz9qbZ9FrJC0AOw==); +} + +#mod-list th.headerSortDown { + background-image: url(data:image/gif;base64,R0lGODlhFQAEAIABAAAAAAAAACH/C1hNUCBEYXRhWE1QPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS4wLWMwNjAgNjEuMTM0Nzc3LCAyMDEwLzAyLzEyLTE3OjMyOjAwICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtbG5zOnhtcD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLyIgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjAyODAxMTc0MDcyMDY4MTE4OEM2REYyN0ExMDhBNDJFIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjhFNzNGQjI3MEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjhFNzNGQjI2MEY4NjExRTBBMzkyQzAyM0E1RDk3RDc3IiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzUgTWFjaW50b3NoIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6MDI4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6MDI4MDExNzQwNzIwNjgxMTg4QzZERjI3QTEwOEE0MkUiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4B//79/Pv6+fj39vX08/Lx8O/u7ezr6uno5+bl5OPi4eDf3t3c29rZ2NfW1dTT0tHQz87NzMvKycjHxsXEw8LBwL++vby7urm4t7a1tLOysbCvrq2sq6qpqKempaSjoqGgn56dnJuamZiXlpWUk5KRkI+OjYyLiomIh4aFhIOCgYB/fn18e3p5eHd2dXRzcnFwb25tbGtqaWhnZmVkY2JhYF9eXVxbWllYV1ZVVFNSUVBPTk1MS0pJSEdGRURDQkFAPz49PDs6OTg3NjU0MzIxMC8uLSwrKikoJyYlJCMiISAfHh0cGxoZGBcWFRQTEhEQDw4NDAsKCQgHBgUEAwIBAAAh+QQBAAABACwAAAAAFQAEAAACDYyPAcmtsJyDVDKKWQEAOw==); +} + +#mod-list .mod-page-links, +#mod-list .mod-broke-in { + font-size: 0.9em; +} + +#mod-list .mod-alt-authors, +#mod-list .mod-alt-names { + font-size: 0.8em; +} + +#mod-list .mod-alt-authors, +#mod-list .mod-alt-names { + display: block; +} + +#mod-list tr[data-status="ok"], +#mod-list tr[data-status="optional"] { + background: #BFB; +} + +#mod-list tr[data-status="workaround"], +#mod-list tr[data-status="unofficial"] { + background: #FFFEC6; +} + +#mod-list tr[data-status="broken"] { + background: #FBB; +} + +#mod-list tr[data-status="obsolete"], +#mod-list tr[data-status="abandoned"] { + background: #BBB; + opacity: 0.7; +} + +#mod-list .mod-closed-source { + color: red; + font-size: 0.8em; + opacity: 0.5; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/privacy.css b/src/SMAPI.Web/wwwroot/Content/css/privacy.css new file mode 100644 index 00000000..94bc68a9 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/privacy.css @@ -0,0 +1,3 @@ +h3 { + border: 0; +} diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js new file mode 100644 index 00000000..2cff551f --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -0,0 +1,205 @@ +/* globals $ */ + +var smapi = smapi || {}; +var app; +smapi.modList = function (mods) { + // init data + var data = { + mods: mods, + visibleCount: mods.length, + showAdvanced: false, + filters: { + source: { + open: { + label: "open", + id: "show-open-source", + value: true + }, + closed: { + label: "closed", + id: "show-closed-source", + value: true + } + }, + status: { + ok: { + label: "ok", + id: "show-status-ok", + value: true + }, + optional: { + label: "optional", + id: "show-status-optional", + value: true + }, + unofficial: { + label: "unofficial", + id: "show-status-unofficial", + value: true + }, + workaround: { + label: "workaround", + id: "show-status-workaround", + value: true + }, + broken: { + label: "broken", + id: "show-status-broken", + value: true + }, + abandoned: { + label: "abandoned", + id: "show-status-abandoned", + value: true + }, + obsolete: { + label: "obsolete", + id: "show-status-obsolete", + value: true + } + }, + download: { + chucklefish: { + label: "Chucklefish", + id: "show-chucklefish", + value: true + }, + nexus: { + label: "Nexus", + id: "show-nexus", + value: true + }, + custom: { + label: "custom", + id: "show-custom", + value: true + } + } + }, + search: "" + }; + for (var i = 0; i < data.mods.length; i++) { + var mod = mods[i]; + + // set initial visibility + mod.Visible = true; + + // concatenate searchable text + mod.SearchableText = [mod.Name, mod.AlternateNames, mod.Author, mod.AlternateAuthors, mod.Compatibility.Summary, mod.BrokeIn]; + if (mod.Compatibility.UnofficialVersion) + mod.SearchableText.push(mod.Compatibility.UnofficialVersion); + if (mod.BetaCompatibility) { + mod.SearchableText.push(mod.BetaCompatibility.Summary); + if (mod.BetaCompatibility.UnofficialVersion) + mod.SearchableText.push(mod.BetaCompatibility.UnofficialVersion); + } + for (var p = 0; p < mod.ModPages; p++) + mod.SearchableField.push(mod.ModPages[p].Text); + mod.SearchableText = mod.SearchableText.join(" ").toLowerCase(); + } + + // init app + app = new Vue({ + el: "#app", + data: data, + mounted: function() { + // enable table sorting + $("#mod-list").tablesorter({ + cssHeader: "header", + cssAsc: "headerSortUp", + cssDesc: "headerSortDown" + }); + + // put focus in textbox for quick search + if (!location.hash) + $("#search-box").focus(); + + // jump to anchor (since table is added after page load) + if (location.hash) { + var row = $(location.hash).get(0); + if (row) + row.scrollIntoView(); + } + }, + methods: { + /** + * Update the visibility of all mods based on the current search text and filters. + */ + applyFilters: function () { + // get search terms + var words = data.search.toLowerCase().split(" "); + + // apply criteria + data.visibleCount = data.mods.length; + for (var i = 0; i < data.mods.length; i++) { + var mod = data.mods[i]; + mod.Visible = true; + + // check filters + if (!this.matchesFilters(mod)) { + mod.Visible = false; + data.visibleCount--; + continue; + } + + // check search terms (all search words should match) + if (words.length) { + for (var w = 0; w < words.length; w++) { + if (mod.SearchableText.indexOf(words[w]) === -1) { + mod.Visible = false; + data.visibleCount--; + break; + } + } + } + } + }, + + + /** + * Get whether a mod matches the current filters. + * @param {object} mod The mod to check. + * @returns {bool} Whether the mod matches the filters. + */ + matchesFilters: function(mod) { + var filters = data.filters; + + // check source + if (!filters.source.open.value && mod.SourceUrl) + return false; + if (!filters.source.closed.value && !mod.SourceUrl) + return false; + + // check status + var status = (mod.BetaCompatibility || mod.Compatibility).Status; + if (filters.status[status] && !filters.status[status].value) + return false; + + // check download sites + var ignoreSites = []; + + if (!filters.download.chucklefish.value) + ignoreSites.push("Chucklefish"); + if (!filters.download.nexus.value) + ignoreSites.push("Nexus"); + if (!filters.download.custom.value) + ignoreSites.push("custom"); + + if (ignoreSites.length) { + var anyLeft = false; + for (var i = 0; i < mod.ModPageSites.length; i++) { + if (ignoreSites.indexOf(mod.ModPageSites[i]) === -1) { + anyLeft = true; + break; + } + } + + if (!anyLeft) + return false; + } + + return true; + } + } + }); +}; diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json index c95abe75..b16cb99f 100644 --- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json @@ -68,51 +68,10 @@ /********* ** Mods *********/ - "AccessChestAnywhere": { - "ID": "AccessChestAnywhere", - "MapLocalVersions": { "1.1-1078": "1.1" }, - "Default | UpdateKey": "Nexus:257", - "~1.1 | Status": "AssumeBroken" - }, - "Adjust Artisan Prices": { "ID": "ThatNorthernMonkey.AdjustArtisanPrices", "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update - "MapRemoteVersions": { "0.01": "0.0.1" }, - "Default | UpdateKey": "Chucklefish:3532" - }, - - "Adjust Monster": { - "ID": "mmanlapat.AdjustMonster", - "Default | UpdateKey": "Nexus:1161" - }, - - "Advanced Location Loader": { - "ID": "Entoarox.AdvancedLocationLoader", - "~1.3.7 | UpdateKey": "Chucklefish:3619" // only enable update checks up to 1.3.7 by request (has its own update-check feature) - }, - - "Adventure Shop Inventory": { - "ID": "HammurabiAdventureShopInventory", - "Default | UpdateKey": "Chucklefish:4608" - }, - - "AgingMod": { - "ID": "skn.AgingMod", - "Default | UpdateKey": "Nexus:1129", - "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "All Crops All Seasons": { - "ID": "cantorsdust.AllCropsAllSeasons", - "FormerIDs": "29ee8246-d67b-4242-a340-35a9ae0d5dd7 | community.AllCropsAllSeasons", // changed in 1.3 and 1.5 - "Default | UpdateKey": "Nexus:170" - }, - - "All Professions": { - "ID": "cantorsdust.AllProfessions", - "FormerIDs": "8c37b1a7-4bfb-4916-9d8a-9533e6363ea3 | community.AllProfessions", // changed in 1.2 and 1.3.1 - "Default | UpdateKey": "Nexus:174" + "MapRemoteVersions": { "0.01": "0.0.1" } }, "Almighty Farming Tool": { @@ -120,14 +79,7 @@ "MapRemoteVersions": { "1.21": "1.2.1", "1.22-unofficial.3.mizzion": "1.2.2-unofficial.3.mizzion" - }, - "Default | UpdateKey": "Nexus:439" - }, - - "Animal Husbandry": { - "ID": "DIGUS.ANIMALHUSBANDRYMOD", - "FormerIDs": "DIGUS.BUTCHER", // changed in 2.0.1 - "Default | UpdateKey": "Nexus:1538" + } }, "Animal Mood Fix": { @@ -136,163 +88,46 @@ "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." }, - "Animal Sitter": { - "ID": "jwdred.AnimalSitter", - "Default | UpdateKey": "Nexus:581", - "~1.0.8 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Arcade Pong": { "ID": "Platonymous.ArcadePong", "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals }, - "A Tapper's Dream": { - "ID": "ddde5195-8f85-4061-90cc-0d4fd5459358", - "Default | UpdateKey": "Nexus:260" - }, - - "Auto Animal Doors": { - "ID": "AaronTaggart.AutoAnimalDoors", - "Default | UpdateKey": "Nexus:1019" - }, - - "Auto-Eat": { - "ID": "Permamiss.AutoEat", - "FormerIDs": "BALANCEMOD_AutoEat", // changed in 1.1.1 - "Default | UpdateKey": "Nexus:643" - }, - - "AutoFish": { - "ID": "WhiteMind.AF", - "Default | UpdateKey": "Nexus:1895" - }, - - "AutoGate": { - "ID": "AutoGate", - "Default | UpdateKey": "Nexus:820" - }, - - "Automate": { - "ID": "Pathoschild.Automate", - "Default | UpdateKey": "Nexus:1063", - "~1.10-beta.7 | Status": "AssumeBroken" // broke in SDV 1.3.20 - }, - "Automated Doors": { "ID": "azah.automated-doors", "FormerIDs": "1abcfa07-2cf4-4dc3-a6e9-6068b642112b", // changed in 1.4.1 "Default | UpdateKey": "GitHub:azah/AutomatedDoors" // added in 1.4.2 }, - "AutoSpeed": { - "ID": "Omegasis.AutoSpeed", - "Default | UpdateKey": "Nexus:443" // added in 1.4.1 - }, - "Basic Sprinklers Improved": { "ID": "lrsk_sdvm_bsi.0117171308", - "MapRemoteVersions": { "1.0.2": "1.0.1-release" }, // manifest not updated - "Default | UpdateKey": "Nexus:833" - }, - - "Better Hay": { - "ID": "cat.betterhay", - "Default | UpdateKey": "Nexus:1430" - }, - - "Better Quality More Seasons": { - "ID": "SB_BQMS", - "Default | UpdateKey": "Nexus:935" - }, - - "Better Quarry": { - "ID": "BetterQuarry", - "Default | UpdateKey": "Nexus:771" - }, - - "Better Ranching": { - "ID": "BetterRanching", - "Default | UpdateKey": "Nexus:859" + "MapRemoteVersions": { "1.0.2": "1.0.1-release" } // manifest not updated }, "Better Shipping Box": { "ID": "Kithio:BetterShippingBox", - "MapLocalVersions": { "1.0.1": "1.0.2" }, - "Default | UpdateKey": "Chucklefish:4302" - }, - - "Better Sprinklers": { - "ID": "Speeder.BetterSprinklers", - "FormerIDs": "SPDSprinklersMod", // changed in 2.3 - "Default | UpdateKey": "Nexus:41" - }, - - "Billboard Anywhere": { - "ID": "Omegasis.BillboardAnywhere", - "Default | UpdateKey": "Nexus:492" // added in 1.4.1 + "MapLocalVersions": { "1.0.1": "1.0.2" } }, "Birthday Mail": { "ID": "KathrynHazuka.BirthdayMail", "FormerIDs": "005e02dc-d900-425c-9c68-1ff55c5a295d", // changed in 1.2.3-pathoschild-update - "Default | UpdateKey": "Nexus:276", "MapRemoteVersions": { "1.3.1": "1.3" } // manifest not updated }, - "Breed Like Rabbits": { - "ID": "dycedarger.breedlikerabbits", - "Default | UpdateKey": "Nexus:948" - }, - - "Build Endurance": { - "ID": "Omegasis.BuildEndurance", - "Default | UpdateKey": "Nexus:445" // added in 1.4.1 - }, - - "Build Health": { - "ID": "Omegasis.BuildHealth", - "Default | UpdateKey": "Nexus:446" // added in 1.4.1 - }, - - "Buy Cooking Recipes": { - "ID": "Denifia.BuyRecipes", - "Default | UpdateKey": "Nexus:1126" // added in 1.0.1 (2017-10-04) - }, - - "Buy Back Collectables": { - "ID": "Omegasis.BuyBackCollectables", - "FormerIDs": "BuyBackCollectables", // changed in 1.4 - "Default | UpdateKey": "Nexus:507" // added in 1.4.1 - }, - - "Carry Chest": { - "ID": "spacechase0.CarryChest", - "Default | UpdateKey": "Nexus:1333" + "BJS Night Sounds": { + "ID": "BunnyJumps.BJSNightSounds", + "~1.0.0 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ }, "Casks Anywhere": { "ID": "CasksAnywhere", - "MapLocalVersions": { "1.1-alpha": "1.1" }, - "Default | UpdateKey": "Nexus:878" - }, - - "Categorize Chests": { - "ID": "CategorizeChests", - "Default | UpdateKey": "Nexus:1300", - "~1.4.3-unofficial.2.mizzion | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors) + "MapLocalVersions": { "1.1-alpha": "1.1" } }, "Chefs Closet": { "ID": "Duder.ChefsCloset", - "MapLocalVersions": { "1.3-1": "1.3" }, - "Default | UpdateKey": "Nexus:1030" - }, - - "Chest Label System": { - "ID": "Speeder.ChestLabel", - "FormerIDs": "SPDChestLabel", // changed in 1.5.1-pathoschild-update - "Default | UpdateKey": "Nexus:242" + "MapLocalVersions": { "1.3-1": "1.3" } }, "Chest Pooling": { @@ -300,401 +135,81 @@ "Default | UpdateKey": "GitHub:mralbobo/stardew-chest-pooling" }, - "Chests Anywhere": { - "ID": "Pathoschild.ChestsAnywhere", - "FormerIDs": "ChestsAnywhere", // changed in 1.9 - "Default | UpdateKey": "Nexus:518", - "~1.12.4 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "CJB Automation": { - "ID": "CJBAutomation", - "Default | UpdateKey": "Nexus:211", - "~1.4 | Status": "AssumeBroken", // broke in SDV 1.2 - "~1.4 | AlternativeUrl": "http://www.nexusmods.com/stardewvalley/mods/1063" - }, - - "CJB Cheats Menu": { - "ID": "CJBok.CheatsMenu", - "FormerIDs": "CJBCheatsMenu", // changed in 1.14 - "Default | UpdateKey": "Nexus:4", - "~1.18-beta | Status": "AssumeBroken" // broke in SDV 1.3, first beta causes significant friendship bugs - }, - - "CJB Item Spawner": { - "ID": "CJBok.ItemSpawner", - "FormerIDs": "CJBItemSpawner", // changed in 1.7 - "Default | UpdateKey": "Nexus:93", - "~1.10 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "CJB Show Item Sell Price": { - "ID": "CJBok.ShowItemSellPrice", - "FormerIDs": "CJBShowItemSellPrice", // changed in 1.7 - "Default | UpdateKey": "Nexus:5", - "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Clean Farm": { - "ID": "tstaples.CleanFarm", - "Default | UpdateKey": "Nexus:794" - }, - - "Climates of Ferngill": { - "ID": "KoihimeNakamura.ClimatesOfFerngill", - "Default | UpdateKey": "Nexus:604" - }, - - "Coal Regen": { - "ID": "Blucifer.CoalRegen", - "Default | UpdateKey": "Nexus:1664" - }, - "Cobalt": { "ID": "spacechase0.Cobalt", "MapRemoteVersions": { "1.1.3": "1.1.2" } // not updated in manifest }, - "Cold Weather Haley": { - "ID": "LordXamon.ColdWeatherHaleyPRO", - "Default | UpdateKey": "Nexus:1169", - "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Colored Chests": { "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", "~ | Status": "Obsolete", "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." }, - "Combat with Farm Implements": { - "ID": "SPDFarmingImplementsInCombat", - "Default | UpdateKey": "Nexus:313" - }, - - "Community Bundle Item Tooltip": { - "ID": "musbah.bundleTooltip", - "Default | UpdateKey": "Nexus:1329" - }, - - "Concentration on Farming": { - "ID": "punyo.ConcentrationOnFarming", - "Default | UpdateKey": "Nexus:1445" - }, - "Configurable Machines": { "ID": "21da6619-dc03-4660-9794-8e5b498f5b97", - "MapLocalVersions": { "1.2-beta": "1.2" }, - "Default | UpdateKey": "Nexus:280" - }, - - "Configurable Shipping Dates": { - "ID": "ConfigurableShippingDates", - "Default | UpdateKey": "Nexus:675", - "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Content Patcher": { - "ID": "Pathoschild.ContentPatcher", - "Default | UpdateKey": "Nexus:1915", - "~1.4-beta.5 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.18 (in-game errors) - }, - - "Cooking Skill": { - "ID": "spacechase0.CookingSkill", - "FormerIDs": "CookingSkill", // changed in 1.0.4–6 - "Default | UpdateKey": "Nexus:522" - }, - - "CrabNet": { - "ID": "jwdred.CrabNet", - "Default | UpdateKey": "Nexus:584" + "MapLocalVersions": { "1.2-beta": "1.2" } }, "Crafting Counter": { "ID": "lolpcgaming.CraftingCounter", - "Default | UpdateKey": "Nexus:1585", "MapRemoteVersions": { "1.1": "1.0" } // not updated in manifest }, - "Current Location": { - "ID": "CurrentLocation102120161203", - "Default | UpdateKey": "Nexus:638" - }, - - "Custom Asset Modifier": { - "ID": "Omegasis.CustomAssetModifier", - "Default | UpdateKey": "1836" - }, - - "Custom Critters": { - "ID": "spacechase0.CustomCritters", - "Default | UpdateKey": "Nexus:1255" - }, - - "Custom Crops": { - "ID": "spacechase0.CustomCrops", - "Default | UpdateKey": "Nexus:1592" - }, - - "Custom Element Handler": { - "ID": "Platonymous.CustomElementHandler", - "Default | UpdateKey": "Nexus:1068" // added in 1.3.1 - }, - - "Custom Farming Redux": { - "ID": "Platonymous.CustomFarming", - "Default | UpdateKey": "Nexus:991" // added in 0.6.1 - }, - "Custom Farming Automate Bridge": { "ID": "Platonymous.CFAutomate", "~1.0.1 | Status": "AssumeBroken", // no longer compatible with Automate "~1.0.1 | AlternativeUrl": "https://www.nexusmods.com/stardewvalley/mods/991" }, - "Custom Farm Types": { - "ID": "spacechase0.CustomFarmTypes", - "Default | UpdateKey": "Nexus:1140" - }, - - "Custom Furniture": { - "ID": "Platonymous.CustomFurniture", - "Default | UpdateKey": "Nexus:1254" // added in 0.4.1 - }, - - "Customize Exterior": { - "ID": "spacechase0.CustomizeExterior", - "FormerIDs": "CustomizeExterior", // changed in 1.0.3 - "Default | UpdateKey": "Nexus:1099", - "~1.0.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Customizable Cart Redux": { "ID": "KoihimeNakamura.CCR", - "MapLocalVersions": { "1.1-20170917": "1.1" }, - "Default | UpdateKey": "Nexus:1402" - }, - - "Customizable Traveling Cart Days": { - "ID": "TravelingCartYyeahdude", - "Default | UpdateKey": "Nexus:567" + "MapLocalVersions": { "1.1-20170917": "1.1" } }, "Custom Linens": { "ID": "Mevima.CustomLinens", - "MapRemoteVersions": { "1.1": "1.0" }, // manifest not updated - "Default | UpdateKey": "Nexus:1027" - }, - - "Custom NPC": { - "ID": "Platonymous.CustomNPC", - "Default | UpdateKey": "Nexus:1607" - }, - - "Custom Shops Redux": { - "ID": "Omegasis.CustomShopReduxGui", - "Default | UpdateKey": "Nexus:1378" // added in 1.4.1 - }, - - "Custom TV": { - "ID": "Platonymous.CustomTV", - "Default | UpdateKey": "Nexus:1139" // added in 1.0.6 + "MapRemoteVersions": { "1.1": "1.0" } // manifest not updated }, - "Daily Luck Message": { - "ID": "Schematix.DailyLuckMessage", - "Default | UpdateKey": "Nexus:1327" - }, - - "Daily News": { - "ID": "bashNinja.DailyNews", - "Default | UpdateKey": "Nexus:1141", - "~1.2 | Status": "AssumeBroken" // broke in Stardew Valley 1.3 (or depends on CustomTV which broke) - }, - - "Daily Quest Anywhere": { - "ID": "Omegasis.DailyQuestAnywhere", - "FormerIDs": "DailyQuest", // changed in 1.4 - "Default | UpdateKey": "Nexus:513" // added in 1.4.1 - }, - - "Data Maps": { - "ID": "Pathoschild.DataMaps", - "Default | UpdateKey": "Nexus:1691", - "~1.4 | Status": "AssumeBroken" // replaced by Data Layers - }, - - "Debug Mode": { - "ID": "Pathoschild.DebugMode", - "FormerIDs": "Pathoschild.Stardew.DebugMode", // changed in 1.4 - "Default | UpdateKey": "Nexus:679", - "~1.8 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Did You Water Your Crops?": { - "ID": "Nishtra.DidYouWaterYourCrops", - "Default | UpdateKey": "Nexus:1583" - }, - - "Dynamic Checklist": { - "ID": "gunnargolf.DynamicChecklist", - "Default | UpdateKey": "Nexus:1145" // added in 1.0.1-pathoschild-update + "Custom Shirts": { + "ID": "Platonymous.CustomShirts", + "Default | UpdateKey": "Nexus:2416" // keep for dependencies }, "Dynamic Horses": { "ID": "Bpendragon-DynamicHorses", - "MapRemoteVersions": { "1.2": "1.1-release" }, // manifest not updated - "Default | UpdateKey": "Nexus:874" + "MapRemoteVersions": { "1.2": "1.1-release" } // manifest not updated }, "Dynamic Machines": { "ID": "DynamicMachines", - "MapLocalVersions": { "1.1": "1.1.1" }, - "Default | UpdateKey": "Nexus:374", - "~1.1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Dynamic NPC Sprites": { - "ID": "BashNinja.DynamicNPCSprites", - "Default | UpdateKey": "Nexus:1183" - }, - - "Easier Farming": { - "ID": "cautiouswafffle.EasierFarming", - "Default | UpdateKey": "Nexus:1426" - }, - - "Empty Hands": { - "ID": "QuicksilverFox.EmptyHands", - "Default | UpdateKey": "Nexus:1176" // added in 1.0.1-pathoschild-update + "MapLocalVersions": { "1.1": "1.1.1" } }, "Enemy Health Bars": { "ID": "Speeder.HealthBars", - "FormerIDs": "SPDHealthBar", // changed in 1.7.1-pathoschild-update - "Default | UpdateKey": "Nexus:193" - }, - - "Entoarox Framework": { - "ID": "Entoarox.EntoaroxFramework", - "FormerIDs": "eacdb74b-4080-4452-b16b-93773cda5cf9", // changed in ??? - "~2.0.6 | UpdateKey": "Chucklefish:4228", // only enable update checks up to 2.0.6 by request (has its own update-check feature) - "~2.0.6 | Status": "AssumeBroken" // broke in SMAPI 2.5 (error reflecting into SMAPI internals) - }, - - "Expanded Fridge": { - "ID": "Uwazouri.ExpandedFridge", - "Default | UpdateKey": "Nexus:1191" - }, - - "Experience Bars": { - "ID": "spacechase0.ExperienceBars", - "FormerIDs": "ExperienceBars", // changed in 1.0.2 - "Default | UpdateKey": "Nexus:509" - }, - - "Extended Bus System": { - "ID": "ExtendedBusSystem", - "Default | UpdateKey": "Chucklefish:4373" - }, - - "Extended Fridge": { - "ID": "Crystalmir.ExtendedFridge", - "FormerIDs": "Mystra007ExtendedFridge", // changed in 1.0.1 - "Default | UpdateKey": "Nexus:485" - }, - - "Extended Greenhouse": { - "ID": "ExtendedGreenhouse", - "Default | UpdateKey": "Chucklefish:4303", - "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Extended Minecart": { - "ID": "Entoarox.ExtendedMinecart", - "~1.7.1 | UpdateKey": "Chucklefish:4359" // only enable update checks up to 1.7.1 by request (has its own update-check feature) - }, - - "Extended Reach": { - "ID": "spacechase0.ExtendedReach", - "Default | UpdateKey": "Nexus:1493" + "FormerIDs": "SPDHealthBar" // changed in 1.7.1-pathoschild-update }, "Fall 28 Snow Day": { "ID": "Omegasis.Fall28SnowDay", - "Default | UpdateKey": "Nexus:486", // added in 1.4.1 "~1.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0, and update for SMAPI 2.0 doesn't do anything }, - "Farm Automation Unofficial: Item Collector": { - "ID": "Maddy99.FarmAutomation.ItemCollector", - "~0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Farm Expansion": { - "ID": "Advize.FarmExpansion", - "FormerIDs": "3888bdfd-73f6-4776-8bb7-8ad45aea1915 | AdvizeFarmExpansionMod-2-0 | AdvizeFarmExpansionMod-2-0-5", // changed in 2.0, 2.0.5, and 3.0 - "Default | UpdateKey": "Nexus:130" - }, - - "Fast Animations": { - "ID": "Pathoschild.FastAnimations", - "Default | UpdateKey": "Nexus:1089", - "~1.5 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Faster Grass": { - "ID": "IceGladiador.FasterGrass", - "Default | UpdateKey": "Nexus:1772" - }, - - "Faster Paths": { - "ID": "Entoarox.FasterPaths", - "FormerIDs": "615f85f8-5c89-44ee-aecc-c328f172e413", // changed in 1.3 - "~1.3.3 | UpdateKey": "Chucklefish:3641" // only enable update checks up to 1.3.3 by request (has its own update-check feature) - }, - "Fishing Adjust": { "ID": "shuaiz.FishingAdjustMod", - "Default | UpdateKey": "Nexus:1350", - "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)' + "~2.0.1 | Status": "AssumeBroken" // Method not found: 'Void Harmony.HarmonyInstance.Patch(System.Reflection.MethodBase, Harmony.HarmonyMethod, Harmony.HarmonyMethod, Harmony.HarmonyMethod)' }, - "Fishing Tuner Redux": { - "ID": "HammurabiFishingTunerRedux", - "Default | UpdateKey": "Chucklefish:4578" - }, - - "Fixed Secret Woods Debris": { - "ID": "f4iTh.WoodsDebrisFix", - "Default | UpdateKey": "Nexus:1941" + "Fishing Automaton": { + "ID": "Drynwynn.FishingAutomaton", + "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ }, "Fix Scythe Exp": { "ID": "bcmpinc.FixScytheExp", - "~0.2 | Status": "AssumeBroken" // Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. - }, - - "Flower Color Picker": { - "ID": "spacechase0.FlowerColorPicker", - "Default | UpdateKey": "Nexus:1229" - }, - - "Forage at the Farm": { - "ID": "Nishtra.ForageAtTheFarm", - "FormerIDs": "ForageAtTheFarm", // changed in <=1.6 - "Default | UpdateKey": "Nexus:673" - }, - - "Furniture Anywhere": { - "ID": "Entoarox.FurnitureAnywhere", - "~1.1.5 | UpdateKey": "Chucklefish:4324" // only enable update checks up to 1.1.5 by request (has its own update-check feature) - }, - - "Game Reminder": { - "ID": "mmanlapat.GameReminder", - "Default | UpdateKey": "Nexus:1153" + "~0.3 | Status": "AssumeBroken" // broke in 1.3: Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. }, "Gate Opener": { @@ -702,231 +217,24 @@ "Default | UpdateKey": "GitHub:mralbobo/stardew-gate-opener" }, - "GenericShopExtender": { - "ID": "GenericShopExtender", - "Default | UpdateKey": "Nexus:814" // added in 0.1.3 - }, - - "Geode Info Menu": { - "ID": "cat.geodeinfomenu", - "Default | UpdateKey": "Nexus:1448" - }, - - "Get Dressed": { - "ID": "Advize.GetDressed", - "Default | UpdateKey": "Nexus:331" - }, - - "Giant Crop Ring": { - "ID": "cat.giantcropring", - "Default | UpdateKey": "Nexus:1182" - }, - - "Gift Taste Helper": { - "ID": "tstaples.GiftTasteHelper", - "FormerIDs": "8008db57-fa67-4730-978e-34b37ef191d6", // changed in 2.5 - "Default | UpdateKey": "Nexus:229" - }, - - "Grandfather's Gift": { - "ID": "ShadowDragon.GrandfathersGift", - "Default | UpdateKey": "Nexus:985" - }, - - "Happy Animals": { - "ID": "HappyAnimals", - "~1.0.3 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Happy Birthday (Omegasis)": { - "ID": "Omegasis.HappyBirthday", - "Default | UpdateKey": "Nexus:520" // added in 1.4.1 - }, - - "Hardcore Mines": { - "ID": "kibbe.hardcore_mines", - "Default | UpdateKey": "Nexus:1674" - }, - - "Harp of Yoba Redux": { - "ID": "Platonymous.HarpOfYobaRedux", - "Default | UpdateKey": "Nexus:914" // added in 2.0.3 - }, - - "Harvest Moon Witch Princess": { - "ID": "Sasara.WitchPrincess", - "Default | UpdateKey": "Nexus:1157" - }, - - "Harvest With Scythe": { - "ID": "965169fd-e1ed-47d0-9f12-b104535fb4bc", - "Default | UpdateKey": "Nexus:236" - }, - - "Horse Whistle (icepuente)": { - "ID": "icepuente.HorseWhistle", - "Default | UpdateKey": "Nexus:1131", - "~1.1.2-unofficial.1-pathoschild | Status": "AssumeBroken" // causes significant lag, fixed in unofficial.2 - }, - - "Hunger (Yyeadude)": { - "ID": "HungerYyeadude", - "Default | UpdateKey": "Nexus:613" - }, - - "Hunger for Food (Tigerle)": { - "ID": "HungerForFoodByTigerle", - "Default | UpdateKey": "Nexus:810", - "~0.1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 + "Grass Growth": { + "ID": "bcmpinc.GrassGrowth", + "~0.3 | Status": "AssumeBroken" // broke in 1.3.29 (runtime errors: System.IndexOutOfRangeException: Could not find instruction sequence) }, "Hunger Mod (skn)": { "ID": "skn.HungerMod", - "MapRemoteVersions": { "1.2.1": "1.0" }, // manifest not updated - "Default | UpdateKey": "Nexus:1127" + "MapRemoteVersions": { "1.2.1": "1.0" } // manifest not updated }, "Idle Pause": { "ID": "Veleek.IdlePause", - "MapRemoteVersions": { "1.2": "1.1" }, // manifest not updated - "Default | UpdateKey": "Nexus:1092" - }, - - "Improved Quality of Life": { - "ID": "Demiacle.ImprovedQualityOfLife", - "Default | UpdateKey": "Nexus:1025", - "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Instant Geode": { - "ID": "InstantGeode", - "~1.12 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Instant Grow Trees": { - "ID": "cantorsdust.InstantGrowTrees", - "FormerIDs": "dc50c58b-c7d8-4e60-86cc-e27b5d95ee59 | community.InstantGrowTrees", // changed in 1.2 and 1.3.1 - "Default | UpdateKey": "Nexus:173" - }, - - "Interaction Helper": { - "ID": "HammurabiInteractionHelper", - "Default | UpdateKey": "Chucklefish:4640" // added in 1.0.4-pathoschild-update + "MapRemoteVersions": { "1.2": "1.1" } // manifest not updated }, "Item Auto Stacker": { "ID": "cat.autostacker", - "MapRemoteVersions": { "1.0.1": "1.0" }, // manifest not updated - "Default | UpdateKey": "Nexus:1184" - }, - - "Json Assets": { - "ID": "spacechase0.JsonAssets", - "Default | UpdateKey": "Nexus:1720" - }, - - "Junimo Farm": { - "ID": "Platonymous.JunimoFarm", - "MapRemoteVersions": { "1.1.2": "1.1.1" }, // manifest not updated - "Default | UpdateKey": "Nexus:984" // added in 1.1.3 - }, - - "Less Strict Over-Exertion (AntiExhaustion)": { - "ID": "BALANCEMOD_AntiExhaustion", - "MapLocalVersions": { "0.0": "1.1" }, - "Default | UpdateKey": "Nexus:637", - "~1.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Level Extender": { - "ID": "DevinLematty.LevelExtender", - "FormerIDs": "Devin Lematty.Level Extender", // changed in 1.3 - "Default | UpdateKey": "Nexus:1471" - }, - - "Level Up Notifications": { - "ID": "Level Up Notifications", - "MapRemoteVersions": { "0.0.1a": "0.0.1" }, - "Default | UpdateKey": "Nexus:855" - }, - - "Location and Music Logging": { - "ID": "Brandy Lover.LMlog", - "Default | UpdateKey": "Nexus:1366" - }, - - "Longevity": { - "ID": "RTGOAT.Longevity", - "MapRemoteVersions": { "1.6.8h": "1.6.8" }, - "Default | UpdateKey": "Nexus:649" - }, - - "Lookup Anything": { - "ID": "Pathoschild.LookupAnything", - "FormerIDs": "LookupAnything", // changed in 1.10.1 - "Default | UpdateKey": "Nexus:541", - "~1.18.1 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Love Bubbles": { - "ID": "LoveBubbles", - "Default | UpdateKey": "Nexus:1318" - }, - - "Loved Labels": { - "ID": "Advize.LovedLabels", - "Default | UpdateKey": "Nexus:279" - }, - - "Luck Skill": { - "ID": "spacechase0.LuckSkill", - "FormerIDs": "LuckSkill", // changed in 0.1.4 - "Default | UpdateKey": "Nexus:521" - }, - - "Magic": { - "ID": "spacechase0.Magic", - "MapRemoteVersions": { "0.1.2": "0.1.1" } // not updated in manifest - }, - - "Mail Framework": { - "ID": "DIGUS.MailFrameworkMod", - "Default | UpdateKey": "Nexus:1536" - }, - - "MailOrderPigs": { - "ID": "jwdred.MailOrderPigs", - "Default | UpdateKey": "Nexus:632" - }, - - "Makeshift Multiplayer": { - "ID": "spacechase0.StardewValleyMP", - "FormerIDs": "StardewValleyMP", // changed in 0.3 - "Default | UpdateKey": "Nexus:501" - }, - - "Map Image Exporter": { - "ID": "spacechase0.MapImageExporter", - "FormerIDs": "MapImageExporter", // changed in 1.0.2 - "Default | UpdateKey": "Nexus:1073" - }, - - "Message Box [API]? (ChatMod)": { - "ID": "Kithio:ChatMod", - "Default | UpdateKey": "Chucklefish:4296", - "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Mining at the Farm": { - "ID": "Nishtra.MiningAtTheFarm", - "FormerIDs": "MiningAtTheFarm", // changed in <=1.7 - "Default | UpdateKey": "Nexus:674" - }, - - "Mining With Explosives": { - "ID": "Nishtra.MiningWithExplosives", - "FormerIDs": "MiningWithExplosives", // changed in 1.1 - "Default | UpdateKey": "Nexus:770" + "MapRemoteVersions": { "1.0.1": "1.0" } // manifest not updated }, "Modder Serialization Utility": { @@ -935,81 +243,39 @@ "~ | StatusReasonPhrase": "it's no longer maintained or used." }, - "Monster Level Tip": { - "ID": "WhiteMind.MonsterLT", - "Default | UpdateKey": "Nexus:1896" - }, - - "More Animals": { - "ID": "Entoarox.MoreAnimals", - "FormerIDs": "821ce8f6-e629-41ad-9fde-03b54f68b0b6MOREPETS | Entoarox.MorePets", // changed in 1.3 and 2.0 - "~2.0.2 | UpdateKey": "Chucklefish:4288" // only enable update checks up to 2.0.2 by request (has its own update-check feature) - }, - - "More Artifact Spots": { - "ID": "451", - "Default | UpdateKey": "Nexus:451" - }, - - "More Map Layers": { - "ID": "Platonymous.MoreMapLayers", - "Default | UpdateKey": "Nexus:1134" // added in 1.1.1 - }, - "More Rain": { "ID": "Omegasis.MoreRain", - "Default | UpdateKey": "Nexus:441", // added in 1.5.1 "~1.4 | Status": "AssumeBroken" // broke in SMAPI 2.0 }, - "More Weapons": { - "ID": "Joco80.MoreWeapons", - "Default | UpdateKey": "Nexus:1168" + "More Silo Storage": { + "ID": "OrneryWalrus.MoreSiloStorage", + "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.3 }, "Move Faster": { "ID": "shuaiz.MoveFasterMod", - "Default | UpdateKey": "Nexus:1351", - "1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?) - }, - - "Multiple Sprites and Portraits On Rotation (File Loading)": { - "ID": "FileLoading", - "MapLocalVersions": { "1.1": "1.12" }, - "Default | UpdateKey": "Nexus:1094", - "~1.12 | Status": "AssumeBroken" // broke in SMAPI 2.0 + "~1.0.1 | Status": "AssumeBroken" // doesn't do anything as of SDV 1.2.33 (bad Harmony patch?) }, - "Museum Rearranger": { - "ID": "Omegasis.MuseumRearranger", - "Default | UpdateKey": "Nexus:428" // added in 1.4.1 + "MTN": { + "ID": "SgtPickles.MTN", + "~1.2.5 | Status": "AssumeBroken" // replaces Game1.multiplayer, which breaks SMAPI's multiplayer API. }, - "Mushroom Level Tip": { - "ID": "WhiteMind.MLT", - "Default | UpdateKey": "Nexus:1894" - }, - - "New Machines": { - "ID": "F70D4FAB-0AB2-4B78-9F1B-AF2CA2236A59", - "Default | UpdateKey": "Chucklefish:3683", - "~4.2.1343 | Status": "AssumeBroken" // broke in SMAPI 2.0 + "Multiple Sprites and Portraits On Rotation (File Loading)": { + "ID": "FileLoading", + "MapLocalVersions": { "1.1": "1.12" } }, "Night Owl": { "ID": "Omegasis.NightOwl", - "MapLocalVersions": { "2.1": "1.3" }, // 1.3 had wrong version in manifest - "Default | UpdateKey": "Nexus:433" // added in 1.4.1 + "MapLocalVersions": { "2.1": "1.3" } // 1.3 had wrong version in manifest }, - "No Crows": { - "ID": "cat.nocrows", - "Default | UpdateKey": "Nexus:1682" - }, - - "No Kids Ever": { - "ID": "Hangy.NoKidsEver", - "Default | UpdateKey": "Nexus:1464" + "No Added Flying Mine Monsters": { + "ID": "Drynwynn.NoAddedFlyingMineMonsters", + "~1.1 | Status": "AssumeBroken" // runtime errors with Harmony 1.2.0.1 in SMAPI 2.8+ }, "No Debug Mode": { @@ -1018,90 +284,12 @@ "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." }, - "No Fence Decay": { - "ID": "cat.nofencedecay", - "Default | UpdateKey": "Nexus:1180" - }, - - "No More Pets": { - "ID": "Omegasis.NoMorePets", - "FormerIDs": "NoMorePets", // changed in 1.4 - "Default | UpdateKey": "Nexus:506" // added in 1.4.1 - }, - - "No Rumble Horse": { - "ID": "Xangria.NoRumbleHorse", - "Default | UpdateKey": "Nexus:1779" - }, - - "No Soil Decay": { - "ID": "289dee03-5f38-4d8e-8ffc-e440198e8610", - "Default | UpdateKey": "Nexus:237", - "~0.5 | Status": "AssumeBroken" // broke in SDV 1.2 and uses Assembly.GetExecutingAssembly().Location - }, - - "No Soil Decay Redux": { - "ID": "Platonymous.NoSoilDecayRedux", - "Default | UpdateKey": "Nexus:1084" // added in 1.1.9 - }, - - "NPC Map Locations": { - "ID": "Bouhm.NPCMapLocations", - "FormerIDs": "NPCMapLocationsMod", // changed in 2.0 - "Default | UpdateKey": "Nexus:239" - }, - - "Object Time Left": { - "ID": "spacechase0.ObjectTimeLeft", - "Default | UpdateKey": "Nexus:1315" - }, - "OmniFarm": { "ID": "PhthaloBlue.OmniFarm", "FormerIDs": "BlueMod_OmniFarm", // changed in 2.0.2-pathoschild-update "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_OmniFarm" }, - "One Click Shed": { - "ID": "BitwiseJonMods.OneClickShedReloader", - "Default | UpdateKey": "Nexus:2052" - }, - - "Out of Season Bonuses (Seasonal Items)": { - "ID": "midoriarmstrong.seasonalitems", - "Default | UpdateKey": "Nexus:1452" - }, - - "Part of the Community": { - "ID": "SB_PotC", - "Default | UpdateKey": "Nexus:923" - }, - - "PelicanFiber": { - "ID": "jwdred.PelicanFiber", - "Default | UpdateKey": "Nexus:631" - }, - - "PelicanTTS": { - "ID": "Platonymous.PelicanTTS", - "Default | UpdateKey": "Nexus:1079" // added in 1.6.1 - }, - - "Persia the Mermaid - Standalone Custom NPC": { - "ID": "63b9f419-7449-42db-ab2e-440b4d05c073", - "Default | UpdateKey": "Nexus:1419" - }, - - "Persistent Game Options": { - "ID": "Xangria.PersistentGameOptions", - "Default | UpdateKey": "Nexus:1778" - }, - - "Plant on Grass": { - "ID": "Demiacle.PlantOnGrass", - "Default | UpdateKey": "Nexus:1026" - }, - "PyTK - Platonymous Toolkit": { "ID": "Platonymous.Toolkit", "Default | UpdateKey": "Nexus:1726" @@ -1109,383 +297,82 @@ "Point-and-Plant": { "ID": "jwdred.PointAndPlant", - "Default | UpdateKey": "Nexus:572", "MapRemoteVersions": { "1.0.3": "1.0.2" } // manifest not updated }, - "Pony Weight Loss Program": { - "ID": "BadNetCode.PonyWeightLossProgram", - "Default | UpdateKey": "Nexus:1232" - }, - - "Portraiture": { - "ID": "Platonymous.Portraiture", - "Default | UpdateKey": "Nexus:999" // added in 1.3.1 - }, - "Prairie King Made Easy": { "ID": "Mucchan.PrairieKingMadeEasy", - "Default | UpdateKey": "Chucklefish:3594", "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 }, - "Purchasable Recipes": { - "ID": "Paracosm.PurchasableRecipes", - "Default | UpdateKey": "Nexus:1722" - }, - - "Quest Delay": { - "ID": "BadNetCode.QuestDelay", - "Default | UpdateKey": "Nexus:1239" - }, - - "Recatch Legendary Fish": { - "ID": "cantorsdust.RecatchLegendaryFish", - "FormerIDs": "b3af8c31-48f0-43cf-8343-3eb08bcfa1f9 | community.RecatchLegendaryFish", // changed in 1.3 and 1.5.1 - "Default | UpdateKey": "Nexus:172" - }, - - "Regeneration": { - "ID": "HammurabiRegeneration", - "Default | UpdateKey": "Chucklefish:4584" - }, - - "Relationship Bar UI": { - "ID": "RelationshipBar", - "Default | UpdateKey": "Nexus:1009" - }, - - "RelationshipsEnhanced": { - "ID": "relationshipsenhanced", - "Default | UpdateKey": "Chucklefish:4435", - "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Relationship Status": { "ID": "relationshipstatus", - "MapRemoteVersions": { "1.0.5": "1.0.4" }, // not updated in manifest - "Default | UpdateKey": "Nexus:751", - "~1.0.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Rented Tools": { - "ID": "JarvieK.RentedTools", - "Default | UpdateKey": "Nexus:1307" - }, - - "Replanter": { - "ID": "jwdred.Replanter", - "Default | UpdateKey": "Nexus:589" + "MapRemoteVersions": { "1.0.5": "1.0.4" } // not updated in manifest }, "ReRegeneration": { "ID": "lrsk_sdvm_rerg.0925160827", - "MapLocalVersions": { "1.1.2-release": "1.1.2" }, - "Default | UpdateKey": "Chucklefish:4465" - }, - - "Reseed": { - "ID": "Roc.Reseed", - "Default | UpdateKey": "Nexus:887" - }, - - "Reusable Wallpapers and Floors (Wallpaper Retain)": { - "ID": "dae1b553-2e39-43e7-8400-c7c5c836134b", - "Default | UpdateKey": "Nexus:356", - "~1.5 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Ring of Fire": { - "ID": "Platonymous.RingOfFire", - "Default | UpdateKey": "Nexus:1166" // added in 1.0.1 - }, - - "Rope Bridge": { - "ID": "RopeBridge", - "Default | UpdateKey": "Nexus:824" - }, - - "Rotate Toolbar": { - "ID": "Pathoschild.RotateToolbar", - "Default | UpdateKey": "Nexus:1100", - "~1.2.1 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Rush Orders": { - "ID": "spacechase0.RushOrders", - "FormerIDs": "RushOrders", // changed in 1.1 - "Default | UpdateKey": "Nexus:605" - }, - - "Save Anywhere": { - "ID": "Omegasis.SaveAnywhere", - "Default | UpdateKey": "Nexus:444", // added in 2.6.1 - "MapRemoteVersions": { "2.6.2": "2.6.1" } // not updated in manifest + "MapLocalVersions": { "1.1.2-release": "1.1.2" } }, "Save Backup": { "ID": "Omegasis.SaveBackup", - "Default | UpdateKey": "Nexus:435", // added in 1.3.1 "~1.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 }, - "Scroll to Blank": { - "ID": "caraxian.scroll.to.blank", - "Default | UpdateKey": "Chucklefish:4405" - }, - - "Scythe Harvesting": { - "ID": "mmanlapat.ScytheHarvesting", - "FormerIDs": "ScytheHarvesting", // changed in 1.6 - "Default | UpdateKey": "Nexus:1106" - }, - - "SDV Twitch": { - "ID": "MTD.SDVTwitch", - "Default | UpdateKey": "Nexus:1760" - }, - - "Seasonal Immersion": { - "ID": "Entoarox.SeasonalImmersion", - "FormerIDs": "EntoaroxSeasonalHouse | EntoaroxSeasonalBuildings | EntoaroxSeasonalImmersion", // changed in 1.1, 1.6 or earlier, and 1.7 - "~1.11 | UpdateKey": "Chucklefish:4262" // only enable update checks up to 1.11 by request (has its own update-check feature) - }, - - "Seed Bag": { - "ID": "Platonymous.SeedBag", - "Default | UpdateKey": "Nexus:1133" // added in 1.1.2 - }, - - "Seed Catalogue": { - "ID": "spacechase0.SeedCatalogue", - "Default | UpdateKey": "Nexus:1640" - }, - - "Self Service": { - "ID": "JarvieK.SelfService", - "MapRemoteVersions": { "0.2.1": "0.2" }, // manifest not updated - "Default | UpdateKey": "Nexus:1304" - }, - - "Send Items": { - "ID": "Denifia.SendItems", - "Default | UpdateKey": "Nexus:1087" // added in 1.0.3 (2017-10-04) - }, - - "Shed Notifications (BuildingsNotifications)": { - "ID": "TheCroak.BuildingsNotifications", - "Default | UpdateKey": "Nexus:620", - "~0.4.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Shenandoah Project": { - "ID": "Nishtra.ShenandoahProject", - "FormerIDs": "Shenandoah Project", // changed in 1.2 - "MapRemoteVersions": { "1.1.1": "1.1" }, // not updated in manifest - "Default | UpdateKey": "Nexus:756" - }, - - "Ship Anywhere": { - "ID": "spacechase0.ShipAnywhere", - "Default | UpdateKey": "Nexus:1379" - }, - - "Shipment Tracker": { - "ID": "7e474181-e1a0-40f9-9c11-d08a3dcefaf3", - "Default | UpdateKey": "Nexus:321" + "Server Bookmarker": { + "ID": "Ilyaki.ServerBookmarker", + "~1.0.0 | Status": "AssumeBroken" // broke in Stardew Valley 1.3.29 (runtime errors) }, "Shop Expander": { "ID": "Entoarox.ShopExpander", - "FormerIDs": "EntoaroxShopExpander", // changed in 1.5 and 1.5.2; disambiguate from Faster Paths - "MapRemoteVersions": { "1.6.0b": "1.6.0" }, - "~1.6 | UpdateKey": "Chucklefish:4381" // only enable update checks up to 1.6 by request (has its own update-check feature) + "FormerIDs": "EntoaroxShopExpander", // changed in 1.5.2 + "MapRemoteVersions": { "1.6.0b": "1.6.0" } }, "Showcase Mod": { "ID": "Igorious.Showcase", - "MapLocalVersions": { "0.9-500": "0.9" }, - "Default | UpdateKey": "Chucklefish:4487", - "~0.9 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Shroom Spotter": { - "ID": "TehPers.ShroomSpotter", - "Default | UpdateKey": "Nexus:908" - }, - - "Simple Crop Label": { - "ID": "SimpleCropLabel", - "Default | UpdateKey": "Nexus:314" - }, - - "Simple Sound Manager": { - "ID": "Omegasis.SimpleSoundManager", - "Default | UpdateKey": "Nexus:1410" // added in 1.0.1 - }, - - "Simple Sprinklers": { - "ID": "tZed.SimpleSprinkler", - "Default | UpdateKey": "Nexus:76" + "MapLocalVersions": { "0.9-500": "0.9" } }, "Siv's Marriage Mod": { "ID": "6266959802", // official version "FormerIDs": "Siv.MarriageMod | medoli900.Siv's Marriage Mod", // 1.2.3-unofficial versions - "MapLocalVersions": { "0.0": "1.4" }, - "Default | UpdateKey": "Nexus:366" - }, - - "Skill Prestige": { - "ID": "alphablackwolf.skillPrestige", - "FormerIDs": "6b843e60-c8fc-4a25-a67b-4a38ac8dcf9b", // changed circa 1.2.3 - "Default | UpdateKey": "Nexus:569" + "MapLocalVersions": { "0.0": "1.4" } }, "Skill Prestige: Cooking Adapter": { "ID": "Alphablackwolf.CookingSkillPrestigeAdapter", "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1 - "MapRemoteVersions": { "1.2.3": "1.1" }, // manifest not updated - "Default | UpdateKey": "Nexus:569" - }, - - "Skip Intro": { - "ID": "Pathoschild.SkipIntro", - "FormerIDs": "SkipIntro", // changed in 1.4 - "Default | UpdateKey": "Nexus:533", - "~1.7.2 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Skull Cavern Elevator": { - "ID": "SkullCavernElevator", - "Default | UpdateKey": "Nexus:963" + "MapRemoteVersions": { "1.2.3": "1.1" } // manifest not updated }, "Skull Cave Saver": { "ID": "cantorsdust.SkullCaveSaver", "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2 - "Default | UpdateKey": "Nexus:175", "1.3-beta | Status": "AssumeBroken" // doesn't work in multiplayer, no longer maintained }, - "Sleepy Eye": { - "ID": "spacechase0.SleepyEye", - "Default | UpdateKey": "Nexus:1152" - }, - - "Slower Fence Decay": { - "ID": "Speeder.SlowerFenceDecay", - "FormerIDs": "SPDSlowFenceDecay", // changed in 0.5.2-pathoschild-update - "Default | UpdateKey": "Nexus:252", - "~0.5.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Smart Mod": { - "ID": "KuroBear.SmartMod", - "~2.2 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Solar Eclipse Event": { "ID": "KoihimeNakamura.SolarEclipseEvent", - "Default | UpdateKey": "Nexus:897", "MapLocalVersions": { "1.3.1-20180131": "1.3.1" } }, - "SpaceCore": { - "ID": "spacechase0.SpaceCore", - "Default | UpdateKey": "Nexus:1348" - }, - - "Speedster": { - "ID": "Platonymous.Speedster", - "Default | UpdateKey": "Nexus:1102" // added in 1.3.1 - }, - "Split Screen": { "ID": "Ilyaki.SplitScreen", "~3.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.6-beta.16 due to reflection into SMAPI internals }, - "Sprinkler Range": { - "ID": "cat.sprinklerrange", - "Default | UpdateKey": "Nexus:1179" - }, - - "Sprinkles": { - "ID": "Platonymous.Sprinkles", - "Default | UpdateKey": "Chucklefish:4592" - }, - - "Sprint and Dash": { - "ID": "SPDSprintAndDash", - "Default | UpdateKey": "Nexus:235", - "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Sprint and Dash Redux": { - "ID": "littleraskol.SprintAndDashRedux", - "FormerIDs": "lrsk_sdvm_sndr.0921161059", // changed in 1.3 - "Default | UpdateKey": "Chucklefish:4201" - }, - - "StackSplitX": { - "ID": "tstaples.StackSplitX", - "Default | UpdateKey": "Nexus:798" - }, - - "Stardew Config Menu": { - "ID": "Juice805.StardewConfigMenu", - "Default | UpdateKey": "Nexus:1312" - }, - - "Stardew Content Compatibility Layer (SCCL)": { - "ID": "SCCL", - "Default | UpdateKey": "Nexus:889", - "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Stardew Editor Game Integration": { - "ID": "spacechase0.StardewEditor.GameIntegration", - "Default | UpdateKey": "Nexus:1298" - }, - "Stardew Notification": { "ID": "stardewnotification", "Default | UpdateKey": "GitHub:monopandora/StardewNotification" }, - "Stardew Symphony": { - "ID": "Omegasis.StardewSymphony", - "Default | UpdateKey": "Nexus:425" // added in 1.4.1 - }, - - "StarDustCore": { - "ID": "StarDustCore", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "it was only used by earlier versions of Save Anywhere, and is no longer used or maintained." - }, - - "Starting Money": { - "ID": "mmanlapat.StartingMoney", - "FormerIDs": "StartingMoney", // changed in 1.1 - "Default | UpdateKey": "Nexus:1138" - }, - - "StashItemsToChest": { - "ID": "BlueMod_StashItemsToChest", - "Default | UpdateKey": "GitHub:lambui/StardewValleyMod_StashItemsToChest", - "~1.0.1 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - "Stephan's Lots of Crops": { "ID": "stephansstardewcrops", "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated - "Default | UpdateKey": "Chucklefish:4314" - }, - - "Stumps to Hardwood Stumps": { - "ID": "StumpsToHardwoodStumps", - "Default | UpdateKey": "Nexus:691" + "~1.1 | Status": "AssumeBroken" // broke in SDV 1.3 (overwrites vanilla items) }, "Summit Reborn": { @@ -1494,65 +381,9 @@ "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.3 (runtime errors) }, - "Super Greenhouse Warp Modifier": { - "ID": "SuperGreenhouse", - "Default | UpdateKey": "Chucklefish:4334", - "~1.0 | Status": "AssumeBroken" // broke in SMAPI 2.0 - }, - - "Swim Almost Anywhere / Swim Suit": { - "ID": "Platonymous.SwimSuit", - "Default | UpdateKey": "Nexus:1215" // added in 0.5.1 - }, - - "Tapper Ready": { - "ID": "skunkkk.TapperReady", - "Default | UpdateKey": "Nexus:1219" - }, - - "Teh's Fishing Overhaul": { - "ID": "TehPers.FishingOverhaul", - "Default | UpdateKey": "Nexus:866" - }, - - "Teleporter": { - "ID": "Teleporter", - "Default | UpdateKey": "Chucklefish:4374", - "~1.0.2 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "The Long Night": { - "ID": "Pathoschild.TheLongNight", - "Default | UpdateKey": "Nexus:1369", - "~1.1.1 | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "Three-heart Dance Partner": { - "ID": "ThreeHeartDancePartner", - "Default | UpdateKey": "Nexus:500" - }, - - "TimeFreeze": { - "ID": "Omegasis.TimeFreeze", - "FormerIDs": "4108e859-333c-4fec-a1a7-d2e18c1019fe", // changed in 1.2 - "Default | UpdateKey": "Nexus:973" // added in 1.2.1 - }, - "Time Reminder": { "ID": "KoihimeNakamura.TimeReminder", - "MapLocalVersions": { "1.0-20170314": "1.0.2" }, - "Default | UpdateKey": "Nexus:1000" - }, - - "TimeSpeed": { - "ID": "cantorsdust.TimeSpeed", - "FormerIDs": "community.TimeSpeed", // changed in 2.3.3 - "Default | UpdateKey": "Nexus:169" - }, - - "To Do List": { - "ID": "eleanor.todolist", - "Default | UpdateKey": "Nexus:1630" + "MapLocalVersions": { "1.0-20170314": "1.0.2" } }, "Tool Charging": { @@ -1560,134 +391,15 @@ "Default | UpdateKey": "GitHub:mralbobo/stardew-tool-charging" }, - "TractorMod": { - "ID": "Pathoschild.TractorMod", - "FormerIDs": "BlueMod_TractorMod | PhthaloBlue.TractorMod | community.TractorMod", // changed in 3.2, 4.0 beta, and 4.0 - "Default | UpdateKey": "Nexus:1401", - "~4.5-beta | Status": "AssumeBroken" // broke in SDV 1.3 - }, - - "TrainerMod": { - "ID": "SMAPI.TrainerMod", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "replaced by ConsoleCommands, which is added by the SMAPI installer." - }, - - "Tree Transplant": { - "ID": "TreeTransplant", - "Default | UpdateKey": "Nexus:1342" - }, - - "UI Info Suite": { - "ID": "Cdaragorn.UiInfoSuite", - "Default | UpdateKey": "Nexus:1150" - }, - - "UiModSuite": { - "ID": "Demiacle.UiModSuite", - "MapLocalVersions": { "0.5": "1.0" }, // not updated in manifest - "Default | UpdateKey": "Nexus:1023", - "~1.0 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - "Variable Grass": { "ID": "dantheman999.VariableGrass", "Default | UpdateKey": "GitHub:dantheman999301/StardewMods" }, - "Vertical Toolbar": { - "ID": "SB_VerticalToolMenu", - "Default | UpdateKey": "Nexus:943" - }, - - "WarpAnimals": { - "ID": "Symen.WarpAnimals", - "Default | UpdateKey": "Nexus:1400" - }, - - "What Farm Cave / WhatAMush": { - "ID": "WhatAMush", - "Default | UpdateKey": "Nexus:1097" - }, - - "WHats Up": { - "ID": "wHatsUp", - "Default | UpdateKey": "Nexus:1082" - }, - - "Winter Grass": { - "ID": "cat.wintergrass", - "Default | UpdateKey": "Nexus:1601" - }, - - "Xnb Loader": { - "ID": "Entoarox.XnbLoader", - "~1.1.10 | UpdateKey": "Chucklefish:4506" // only enable update checks up to 1.1.10 by request (has its own update-check feature) - }, - - "zDailyIncrease": { - "ID": "zdailyincrease", - "MapRemoteVersions": { "1.3.5": "1.3.4" }, // not updated in manifest - "Default | UpdateKey": "Chucklefish:4247" - }, - "Zoom Out Extreme": { "ID": "RockinMods.ZoomMod", "FormerIDs": "ZoomMod", // changed circa 1.2.1 - "Default | UpdateKey": "Nexus:1326", - "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Zoryn's Better RNG": { - "ID": "Zoryn.BetterRNG", - "FormerIDs": "76b6d1e1-f7ba-4d72-8c32-5a1e6d2716f6", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", - "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Zoryn's Calendar Anywhere": { - "ID": "Zoryn.CalendarAnywhere", - "FormerIDs": "a41c01cd-0437-43eb-944f-78cb5a53002a", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", - "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Zoryn's Durable Fences": { - "ID": "Zoryn.DurableFences", - "FormerIDs": "56d3439c-7b9b-497e-9496-0c4890e8a00e", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" - }, - - "Zoryn's Health Bars": { - "ID": "Zoryn.HealthBars", - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", - "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Zoryn's Fishing Mod": { - "ID": "Zoryn.FishingMod", - "FormerIDs": "fa277b1f-265e-47c3-a84f-cd320cc74949", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" - }, - - "Zoryn's Junimo Deposit Anywhere": { - "ID": "Zoryn.JunimoDepositAnywhere", - "FormerIDs": "f93a4fe8-cade-4146-9335-b5f82fbbf7bc", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", - "~1.7 | Status": "AssumeBroken" // broke in SDV 1.2 - }, - - "Zoryn's Movement Mod": { - "ID": "Zoryn.MovementModifier", - "FormerIDs": "8a632929-8335-484f-87dd-c29d2ba3215d", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods" - }, - - "Zoryn's Regen Mod": { - "ID": "Zoryn.RegenMod", - "FormerIDs": "dfac4383-1b6b-4f33-ae4e-37fc23e5252e", // changed in 1.6 - "Default | UpdateKey": "GitHub:Zoryn4163/SMAPI-Mods", - "~1.6 | Status": "AssumeBroken" // broke in SDV 1.2 + "~0.1 | Status": "AssumeBroken" // broke in SDV 1.2 } } } diff --git a/src/SMAPI.sln b/src/SMAPI.sln index d870c30c..e01a69ba 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -33,7 +33,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB}" ProjectSection(SolutionItems) = preProject - ..\docs\CONTRIBUTING.md = ..\docs\CONTRIBUTING.md ..\docs\mod-build-config.md = ..\docs\mod-build-config.md ..\docs\README.md = ..\docs\README.md ..\docs\release-notes.md = ..\docs\release-notes.md @@ -65,6 +64,19 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit.CoreInterfaces", "StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj", "{D5CFD923-37F1-4BC3-9BE8-E506E202AC28}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{4B1CEB70-F756-4A57-AAE8-8CD78C475F25}" + ProjectSection(SolutionItems) = preProject + ..\.github\CONTRIBUTING.md = ..\.github\CONTRIBUTING.md + ..\.github\SUPPORT.md = ..\.github\SUPPORT.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{F4453AB6-D7D6-447F-A973-956CC777968F}" + ProjectSection(SolutionItems) = preProject + ..\.github\ISSUE_TEMPLATE\bug_report.md = ..\.github\ISSUE_TEMPLATE\bug_report.md + ..\.github\ISSUE_TEMPLATE\feature_request.md = ..\.github\ISSUE_TEMPLATE\feature_request.md + ..\.github\ISSUE_TEMPLATE\general.md = ..\.github\ISSUE_TEMPLATE\general.md + EndProjectSection +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{443ddf81-6aaf-420a-a610-3459f37e5575}*SharedItemsImports = 4 @@ -130,6 +142,8 @@ Global {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} {09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} {0CF97929-B0D0-4D73-B7BF-4FF7191035F9} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} + {F4453AB6-D7D6-447F-A973-956CC777968F} = {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC} diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index bd512fb1..6969bd7e 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -29,10 +29,10 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.7.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.8.1"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.28"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.32"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; @@ -64,11 +64,14 @@ namespace StardewModdingAPI /// <summary>The URL of the SMAPI home page.</summary> internal const string HomePageUrl = "https://smapi.io"; + /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary> + internal static readonly string InternalFilesPath = Program.DllSearchPath; + /// <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"); + internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.config.json"); /// <summary>The file path for the SMAPI metadata file.</summary> - internal static string ApiMetadataPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.metadata.json"); + internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.metadata.json"); /// <summary>The filename prefix used for all SMAPI logs.</summary> internal static string LogNamePrefix { get; } = "SMAPI-"; @@ -79,14 +82,14 @@ namespace StardewModdingAPI /// <summary>The filename extension for SMAPI log files.</summary> internal static string LogExtension { get; } = "txt"; - /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary> + /// <summary>The file path for the log containing 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"); + internal static string FatalCrashMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.crash.marker"); /// <summary>The file path which stores the detected update version for the next run.</summary> - internal static string UpdateMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.update.marker"); + internal static string UpdateMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.update.marker"); /// <summary>The full path to the folder containing mods.</summary> internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); @@ -104,6 +107,26 @@ namespace StardewModdingAPI /********* ** Internal methods *********/ + /// <summary>Get the SMAPI version to recommend for an older game version, if any.</summary> + /// <param name="version">The game version to search.</param> + /// <returns>Returns the compatible SMAPI version, or <c>null</c> if none was found.</returns> + internal static ISemanticVersion GetCompatibleApiVersion(ISemanticVersion version) + { + switch (version.ToString()) + { + case "1.3.28": + return new SemanticVersion(2, 7, 0); + + case "1.2.30": + case "1.2.31": + case "1.2.32": + case "1.2.33": + return new SemanticVersion(2, 5, 5); + } + + return null; + } + /// <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) diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index 3905699e..c7aed81d 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI 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 || Game1.isFestival()); + public static bool IsPlayerFree => Context.IsWorldReady && Game1.currentLocation != null && Game1.activeClickableMenu == null && !Game1.dialogueUp && (!Game1.eventUp || Game1.isFestival()); /// <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; diff --git a/src/SMAPI/Enums/SkillType.cs b/src/SMAPI/Enums/SkillType.cs new file mode 100644 index 00000000..10518ec9 --- /dev/null +++ b/src/SMAPI/Enums/SkillType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace StardewModdingAPI.Enums +{ + /// <summary>The player skill types.</summary> + public enum SkillType + { + /// <summary>The combat skill.</summary> + Combat = Farmer.combatSkill, + + /// <summary>The farming skill.</summary> + Farming = Farmer.farmingSkill, + + /// <summary>The fishing skill.</summary> + Fishing = Farmer.fishingSkill, + + /// <summary>The foraging skill.</summary> + Foraging = Farmer.foragingSkill, + + /// <summary>The mining skill.</summary> + Mining = Farmer.miningSkill, + + /// <summary>The luck skill.</summary> + Luck = Farmer.luckSkill + } +} diff --git a/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs b/src/SMAPI/Events/BuildingListChangedEventArgs.cs index e73b9396..9bc691fc 100644 --- a/src/SMAPI/Events/WorldBuildingListChangedEventArgs.cs +++ b/src/SMAPI/Events/BuildingListChangedEventArgs.cs @@ -7,7 +7,7 @@ using StardewValley.Buildings; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.BuildingListChanged"/> event.</summary> - public class WorldBuildingListChangedEventArgs : EventArgs + public class BuildingListChangedEventArgs : EventArgs { /********* ** Accessors @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The buildings added to the location.</param> /// <param name="removed">The buildings removed from the location.</param> - public WorldBuildingListChangedEventArgs(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed) + public BuildingListChangedEventArgs(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/InputButtonPressedEventArgs.cs b/src/SMAPI/Events/ButtonPressedEventArgs.cs index 8c6844dd..9e6c187f 100644 --- a/src/SMAPI/Events/InputButtonPressedEventArgs.cs +++ b/src/SMAPI/Events/ButtonPressedEventArgs.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Events { /// <summary>Event arguments when a button is pressed.</summary> - public class InputButtonPressedEventArgs : EventArgs + public class ButtonPressedEventArgs : EventArgs { /********* ** Properties @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="button">The button on the controller, keyboard, or mouse.</param> /// <param name="cursor">The cursor position.</param> /// <param name="inputState">The game's current input state.</param> - internal InputButtonPressedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) + internal ButtonPressedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) { this.Button = button; this.Cursor = cursor; diff --git a/src/SMAPI/Events/InputButtonReleasedEventArgs.cs b/src/SMAPI/Events/ButtonReleasedEventArgs.cs index 4b0bc326..2a289bc7 100644 --- a/src/SMAPI/Events/InputButtonReleasedEventArgs.cs +++ b/src/SMAPI/Events/ButtonReleasedEventArgs.cs @@ -4,7 +4,7 @@ using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Events { /// <summary>Event arguments when a button is released.</summary> - public class InputButtonReleasedEventArgs : EventArgs + public class ButtonReleasedEventArgs : EventArgs { /********* ** Properties @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="button">The button on the controller, keyboard, or mouse.</param> /// <param name="cursor">The cursor position.</param> /// <param name="inputState">The game's current input state.</param> - internal InputButtonReleasedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) + internal ButtonReleasedEventArgs(SButton button, ICursorPosition cursor, SInputState inputState) { this.Button = button; this.Cursor = cursor; diff --git a/src/SMAPI/Events/ContentEvents.cs b/src/SMAPI/Events/ContentEvents.cs index 63645258..3ee0560b 100644 --- a/src/SMAPI/Events/ContentEvents.cs +++ b/src/SMAPI/Events/ContentEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the content language changes.</summary> public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged { - add => ContentEvents.EventManager.Content_LocaleChanged.Add(value); - remove => ContentEvents.EventManager.Content_LocaleChanged.Remove(value); + add => ContentEvents.EventManager.Legacy_LocaleChanged.Add(value); + remove => ContentEvents.EventManager.Legacy_LocaleChanged.Remove(value); } diff --git a/src/SMAPI/Events/ControlEvents.cs b/src/SMAPI/Events/ControlEvents.cs index a3994d1d..56a4fa3f 100644 --- a/src/SMAPI/Events/ControlEvents.cs +++ b/src/SMAPI/Events/ControlEvents.cs @@ -20,57 +20,57 @@ namespace StardewModdingAPI.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 { - add => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyboardChanged.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyboardChanged.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyboardChanged.Remove(value); } /// <summary>Raised after the player presses a keyboard key.</summary> public static event EventHandler<EventArgsKeyPressed> KeyPressed { - add => ControlEvents.EventManager.Legacy_Control_KeyPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyPressed.Remove(value); } /// <summary>Raised after the player releases a keyboard key.</summary> public static event EventHandler<EventArgsKeyPressed> KeyReleased { - add => ControlEvents.EventManager.Legacy_Control_KeyReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_KeyReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_KeyReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_KeyReleased.Remove(value); } /// <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 { - add => ControlEvents.EventManager.Legacy_Control_MouseChanged.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_MouseChanged.Remove(value); + add => ControlEvents.EventManager.Legacy_MouseChanged.Add(value); + remove => ControlEvents.EventManager.Legacy_MouseChanged.Remove(value); } /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed { - add => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerButtonPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerButtonPressed.Remove(value); } /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased { - add => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerButtonReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerButtonReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerButtonReleased.Remove(value); } /// <summary>The player pressed a controller trigger button.</summary> public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed { - add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerPressed.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Remove(value); } /// <summary>The player released a controller trigger button.</summary> public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased { - add => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Add(value); - remove => ControlEvents.EventManager.Legacy_Control_ControllerTriggerReleased.Remove(value); + add => ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Add(value); + remove => ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Remove(value); } diff --git a/src/SMAPI/Events/InputCursorMovedEventArgs.cs b/src/SMAPI/Events/CursorMovedEventArgs.cs index 53aac5b3..453743b9 100644 --- a/src/SMAPI/Events/InputCursorMovedEventArgs.cs +++ b/src/SMAPI/Events/CursorMovedEventArgs.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Events { /// <summary>Event arguments when the in-game cursor is moved.</summary> - public class InputCursorMovedEventArgs : EventArgs + public class CursorMovedEventArgs : EventArgs { /********* ** Accessors @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="oldPosition">The previous cursor position.</param> /// <param name="newPosition">The new cursor position.</param> - public InputCursorMovedEventArgs(ICursorPosition oldPosition, ICursorPosition newPosition) + public CursorMovedEventArgs(ICursorPosition oldPosition, ICursorPosition newPosition) { this.OldPosition = oldPosition; this.NewPosition = newPosition; diff --git a/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs b/src/SMAPI/Events/DayEndingEventArgs.cs index 6a42e4f9..5cb433bc 100644 --- a/src/SMAPI/Events/GameLoopLaunchedEventArgs.cs +++ b/src/SMAPI/Events/DayEndingEventArgs.cs @@ -2,6 +2,6 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Launched"/> event.</summary> - public class GameLoopLaunchedEventArgs : EventArgs { } + /// <summary>Event arguments for an <see cref="IGameLoopEvents.DayEnding"/> event.</summary> + public class DayEndingEventArgs : EventArgs { } } diff --git a/src/SMAPI/Events/DayStartedEventArgs.cs b/src/SMAPI/Events/DayStartedEventArgs.cs new file mode 100644 index 00000000..45823628 --- /dev/null +++ b/src/SMAPI/Events/DayStartedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.DayStarted"/> event.</summary> + public class DayStartedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs b/src/SMAPI/Events/DebrisListChangedEventArgs.cs index aad9c24d..1337bd3b 100644 --- a/src/SMAPI/Events/WorldDebrisListChangedEventArgs.cs +++ b/src/SMAPI/Events/DebrisListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.DebrisListChanged"/> event.</summary> - public class WorldDebrisListChangedEventArgs : EventArgs + public class DebrisListChangedEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The debris added to the location.</param> /// <param name="removed">The debris removed from the location.</param> - public WorldDebrisListChangedEventArgs(GameLocation location, IEnumerable<Debris> added, IEnumerable<Debris> removed) + public DebrisListChangedEventArgs(GameLocation location, IEnumerable<Debris> added, IEnumerable<Debris> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/EventArgsInventoryChanged.cs b/src/SMAPI/Events/EventArgsInventoryChanged.cs index 1fdca834..3a2354b6 100644 --- a/src/SMAPI/Events/EventArgsInventoryChanged.cs +++ b/src/SMAPI/Events/EventArgsInventoryChanged.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="inventory">The player's inventory.</param> /// <param name="changedItems">The inventory changes.</param> - public EventArgsInventoryChanged(IList<Item> inventory, List<ItemStackChange> changedItems) + public EventArgsInventoryChanged(IList<Item> inventory, ItemStackChange[] changedItems) { this.Inventory = inventory; this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList(); diff --git a/src/SMAPI/Events/EventArgsLevelUp.cs b/src/SMAPI/Events/EventArgsLevelUp.cs index fe6696d4..e9a697e7 100644 --- a/src/SMAPI/Events/EventArgsLevelUp.cs +++ b/src/SMAPI/Events/EventArgsLevelUp.cs @@ -1,4 +1,5 @@ using System; +using StardewModdingAPI.Enums; namespace StardewModdingAPI.Events { @@ -18,22 +19,22 @@ namespace StardewModdingAPI.Events public enum LevelType { /// <summary>The combat skill.</summary> - Combat, + Combat = SkillType.Combat, /// <summary>The farming skill.</summary> - Farming, + Farming = SkillType.Farming, /// <summary>The fishing skill.</summary> - Fishing, + Fishing = SkillType.Fishing, /// <summary>The foraging skill.</summary> - Foraging, + Foraging = SkillType.Foraging, /// <summary>The mining skill.</summary> - Mining, + Mining = SkillType.Mining, /// <summary>The luck skill.</summary> - Luck + Luck = SkillType.Luck } diff --git a/src/SMAPI/Events/GameEvents.cs b/src/SMAPI/Events/GameEvents.cs index 92879280..952b3570 100644 --- a/src/SMAPI/Events/GameEvents.cs +++ b/src/SMAPI/Events/GameEvents.cs @@ -19,57 +19,57 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the game updates its state (≈60 times per second).</summary> public static event EventHandler UpdateTick { - add => GameEvents.EventManager.Game_UpdateTick.Add(value); - remove => GameEvents.EventManager.Game_UpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_UpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_UpdateTick.Remove(value); } /// <summary>Raised every other tick (≈30 times per second).</summary> public static event EventHandler SecondUpdateTick { - add => GameEvents.EventManager.Game_SecondUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_SecondUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_SecondUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_SecondUpdateTick.Remove(value); } /// <summary>Raised every fourth tick (≈15 times per second).</summary> public static event EventHandler FourthUpdateTick { - add => GameEvents.EventManager.Game_FourthUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_FourthUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_FourthUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_FourthUpdateTick.Remove(value); } /// <summary>Raised every eighth tick (≈8 times per second).</summary> public static event EventHandler EighthUpdateTick { - add => GameEvents.EventManager.Game_EighthUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_EighthUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_EighthUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_EighthUpdateTick.Remove(value); } /// <summary>Raised every 15th tick (≈4 times per second).</summary> public static event EventHandler QuarterSecondTick { - add => GameEvents.EventManager.Game_QuarterSecondTick.Add(value); - remove => GameEvents.EventManager.Game_QuarterSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_QuarterSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_QuarterSecondTick.Remove(value); } /// <summary>Raised every 30th tick (≈twice per second).</summary> public static event EventHandler HalfSecondTick { - add => GameEvents.EventManager.Game_HalfSecondTick.Add(value); - remove => GameEvents.EventManager.Game_HalfSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_HalfSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_HalfSecondTick.Remove(value); } /// <summary>Raised every 60th tick (≈once per second).</summary> public static event EventHandler OneSecondTick { - add => GameEvents.EventManager.Game_OneSecondTick.Add(value); - remove => GameEvents.EventManager.Game_OneSecondTick.Remove(value); + add => GameEvents.EventManager.Legacy_OneSecondTick.Add(value); + remove => GameEvents.EventManager.Legacy_OneSecondTick.Remove(value); } /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> public static event EventHandler FirstUpdateTick { - add => GameEvents.EventManager.Game_FirstUpdateTick.Add(value); - remove => GameEvents.EventManager.Game_FirstUpdateTick.Remove(value); + add => GameEvents.EventManager.Legacy_FirstUpdateTick.Add(value); + remove => GameEvents.EventManager.Legacy_FirstUpdateTick.Remove(value); } diff --git a/src/SMAPI/Events/GameLaunchedEventArgs.cs b/src/SMAPI/Events/GameLaunchedEventArgs.cs new file mode 100644 index 00000000..a4c78754 --- /dev/null +++ b/src/SMAPI/Events/GameLaunchedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.GameLaunched"/> event.</summary> + public class GameLaunchedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/GraphicsEvents.cs b/src/SMAPI/Events/GraphicsEvents.cs index e1ff4ee7..53f04822 100644 --- a/src/SMAPI/Events/GraphicsEvents.cs +++ b/src/SMAPI/Events/GraphicsEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game window is resized.</summary> public static event EventHandler Resize { - add => GraphicsEvents.EventManager.Graphics_Resize.Add(value); - remove => GraphicsEvents.EventManager.Graphics_Resize.Remove(value); + add => GraphicsEvents.EventManager.Legacy_Resize.Add(value); + remove => GraphicsEvents.EventManager.Legacy_Resize.Remove(value); } /**** @@ -29,15 +29,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised before drawing the world to the screen.</summary> public static event EventHandler OnPreRenderEvent { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Remove(value); } /// <summary>Raised after drawing the world to the screen.</summary> public static event EventHandler OnPostRenderEvent { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Remove(value); } /**** @@ -46,15 +46,15 @@ namespace StardewModdingAPI.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 { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderHudEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderHudEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Remove(value); } /// <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 { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderHudEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderHudEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Remove(value); } /**** @@ -63,15 +63,15 @@ namespace StardewModdingAPI.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 { - add => GraphicsEvents.EventManager.Graphics_OnPreRenderGuiEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPreRenderGuiEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Remove(value); } /// <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 { - add => GraphicsEvents.EventManager.Graphics_OnPostRenderGuiEvent.Add(value); - remove => GraphicsEvents.EventManager.Graphics_OnPostRenderGuiEvent.Remove(value); + add => GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Add(value); + remove => GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Remove(value); } diff --git a/src/SMAPI/Events/IDisplayEvents.cs b/src/SMAPI/Events/IDisplayEvents.cs new file mode 100644 index 00000000..dbf8d90f --- /dev/null +++ b/src/SMAPI/Events/IDisplayEvents.cs @@ -0,0 +1,39 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events related to UI and drawing to the screen.</summary> + public interface IDisplayEvents + { + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + event EventHandler<MenuChangedEventArgs> MenuChanged; + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + event EventHandler<RenderingEventArgs> Rendering; + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + event EventHandler<RenderedEventArgs> Rendered; + + /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + event EventHandler<RenderingWorldEventArgs> RenderingWorld; + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> + event EventHandler<RenderedWorldEventArgs> RenderedWorld; + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> + event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu; + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> + event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu; + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> + event EventHandler<RenderingHudEventArgs> RenderingHud; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> + event EventHandler<RenderedHudEventArgs> RenderedHud; + + /// <summary>Raised after the game window is resized.</summary> + event EventHandler<WindowResizedEventArgs> WindowResized; + } +} diff --git a/src/SMAPI/Events/IGameLoopEvents.cs b/src/SMAPI/Events/IGameLoopEvents.cs index a56b3de3..e1900f79 100644 --- a/src/SMAPI/Events/IGameLoopEvents.cs +++ b/src/SMAPI/Events/IGameLoopEvents.cs @@ -6,12 +6,39 @@ namespace StardewModdingAPI.Events public interface IGameLoopEvents { /// <summary>Raised after the game is launched, right before the first update tick. This happens once per game session (unrelated to loading saves). All mods are loaded and initialised at this point, so this is a good time to set up mod integrations.</summary> - event EventHandler<GameLoopLaunchedEventArgs> Launched; + event EventHandler<GameLaunchedEventArgs> GameLaunched; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - event EventHandler<GameLoopUpdatingEventArgs> Updating; + /// <summary>Raised before the game state is updated (≈60 times per second).</summary> + event EventHandler<UpdateTickingEventArgs> UpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - event EventHandler<GameLoopUpdatedEventArgs> Updated; + /// <summary>Raised after the game state is updated (≈60 times per second).</summary> + event EventHandler<UpdateTickedEventArgs> UpdateTicked; + + /// <summary>Raised before the game creates a new save file.</summary> + event EventHandler<SaveCreatingEventArgs> SaveCreating; + + /// <summary>Raised after the game finishes creating the save file.</summary> + event EventHandler<SaveCreatedEventArgs> SaveCreated; + + /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + event EventHandler<SavingEventArgs> Saving; + + /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> + event EventHandler<SavedEventArgs> Saved; + + /// <summary>Raised after the player loads a save slot.</summary> + event EventHandler<SaveLoadedEventArgs> SaveLoaded; + + /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> + event EventHandler<DayStartedEventArgs> DayStarted; + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary> + event EventHandler<DayEndingEventArgs> DayEnding; + + /// <summary>Raised after the in-game clock time changes.</summary> + event EventHandler<TimeChangedEventArgs> TimeChanged; + + /// <summary>Raised after the game returns to the title screen.</summary> + event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle; } } diff --git a/src/SMAPI/Events/IInputEvents.cs b/src/SMAPI/Events/IInputEvents.cs index 8e2ef406..5c40a438 100644 --- a/src/SMAPI/Events/IInputEvents.cs +++ b/src/SMAPI/Events/IInputEvents.cs @@ -6,15 +6,15 @@ namespace StardewModdingAPI.Events public interface IInputEvents { /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - event EventHandler<InputButtonPressedEventArgs> ButtonPressed; + event EventHandler<ButtonPressedEventArgs> ButtonPressed; /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> - event EventHandler<InputButtonReleasedEventArgs> ButtonReleased; + event EventHandler<ButtonReleasedEventArgs> ButtonReleased; /// <summary>Raised after the player moves the in-game cursor.</summary> - event EventHandler<InputCursorMovedEventArgs> CursorMoved; + event EventHandler<CursorMovedEventArgs> CursorMoved; /// <summary>Raised after the player scrolls the mouse wheel.</summary> - event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled; + event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled; } } diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs index cf2f8cb8..bd7ab880 100644 --- a/src/SMAPI/Events/IModEvents.cs +++ b/src/SMAPI/Events/IModEvents.cs @@ -3,13 +3,25 @@ namespace StardewModdingAPI.Events /// <summary>Manages access to events raised by SMAPI.</summary> public interface IModEvents { + /// <summary>Events related to UI and drawing to the screen.</summary> + IDisplayEvents Display { get; } + /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="Input"/> if possible.</summary> IGameLoopEvents GameLoop { get; } /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> IInputEvents Input { get; } + /// <summary>Events raised for multiplayer messages and connections.</summary> + IMultiplayerEvents Multiplayer { get; } + + /// <summary>Events raised when the player data changes.</summary> + IPlayerEvents Player { get; } + /// <summary>Events raised when something changes in the world.</summary> IWorldEvents World { get; } + + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + ISpecialisedEvents Specialised { get; } } } diff --git a/src/SMAPI/Events/IMultiplayerEvents.cs b/src/SMAPI/Events/IMultiplayerEvents.cs new file mode 100644 index 00000000..4a31f48e --- /dev/null +++ b/src/SMAPI/Events/IMultiplayerEvents.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised for multiplayer messages and connections.</summary> + public interface IMultiplayerEvents + { + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived; + + /// <summary>Raised after a mod message is received over the network.</summary> + event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived; + + /// <summary>Raised after the connection with a peer is severed.</summary> + event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected; + } +} diff --git a/src/SMAPI/Events/IPlayerEvents.cs b/src/SMAPI/Events/IPlayerEvents.cs new file mode 100644 index 00000000..81e17b1a --- /dev/null +++ b/src/SMAPI/Events/IPlayerEvents.cs @@ -0,0 +1,17 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events raised when the player data changes.</summary> + public interface IPlayerEvents + { + /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<InventoryChangedEventArgs> InventoryChanged; + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<LevelChangedEventArgs> LevelChanged; + + /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the current player.</summary> + event EventHandler<WarpedEventArgs> Warped; + } +} diff --git a/src/SMAPI/Events/ISpecialisedEvents.cs b/src/SMAPI/Events/ISpecialisedEvents.cs new file mode 100644 index 00000000..928cd05d --- /dev/null +++ b/src/SMAPI/Events/ISpecialisedEvents.cs @@ -0,0 +1,14 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + public interface ISpecialisedEvents + { + /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; + + /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; + } +} diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs index d4efb53b..0ceffcc1 100644 --- a/src/SMAPI/Events/IWorldEvents.cs +++ b/src/SMAPI/Events/IWorldEvents.cs @@ -6,24 +6,24 @@ namespace StardewModdingAPI.Events public interface IWorldEvents { /// <summary>Raised after a game location is added or removed.</summary> - event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged; + event EventHandler<LocationListChangedEventArgs> LocationListChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged; + event EventHandler<BuildingListChangedEventArgs> BuildingListChanged; /// <summary>Raised after debris are added or removed in a location.</summary> - event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged; + event EventHandler<DebrisListChangedEventArgs> DebrisListChanged; /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; + event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; /// <summary>Raised after NPCs are added or removed in a location.</summary> - event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged; + event EventHandler<NpcListChangedEventArgs> NpcListChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged; + event EventHandler<ObjectListChangedEventArgs> ObjectListChanged; /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; + event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; } } diff --git a/src/SMAPI/Events/InputEvents.cs b/src/SMAPI/Events/InputEvents.cs index e62d6ee6..4c1781a5 100644 --- a/src/SMAPI/Events/InputEvents.cs +++ b/src/SMAPI/Events/InputEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> public static event EventHandler<EventArgsInput> ButtonPressed { - add => InputEvents.EventManager.Legacy_Input_ButtonPressed.Add(value); - remove => InputEvents.EventManager.Legacy_Input_ButtonPressed.Remove(value); + add => InputEvents.EventManager.Legacy_ButtonPressed.Add(value); + remove => InputEvents.EventManager.Legacy_ButtonPressed.Remove(value); } /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> public static event EventHandler<EventArgsInput> ButtonReleased { - add => InputEvents.EventManager.Legacy_Input_ButtonReleased.Add(value); - remove => InputEvents.EventManager.Legacy_Input_ButtonReleased.Remove(value); + add => InputEvents.EventManager.Legacy_ButtonReleased.Add(value); + remove => InputEvents.EventManager.Legacy_ButtonReleased.Remove(value); } diff --git a/src/SMAPI/Events/InventoryChangedEventArgs.cs b/src/SMAPI/Events/InventoryChangedEventArgs.cs new file mode 100644 index 00000000..a081611b --- /dev/null +++ b/src/SMAPI/Events/InventoryChangedEventArgs.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IPlayerEvents.InventoryChanged"/> event.</summary> + public class InventoryChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player whose inventory changed.</summary> + public Farmer Player { get; } + + /// <summary>The added items.</summary> + public IEnumerable<Item> Added { get; } + + /// <summary>The removed items.</summary> + public IEnumerable<Item> Removed { get; } + + /// <summary>The items whose stack sizes changed, with the relative change.</summary> + public IEnumerable<ItemStackSizeChange> QuantityChanged { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player whose inventory changed.</param> + /// <param name="changedItems">The inventory changes.</param> + public InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems) + { + this.Player = player; + this.Added = changedItems + .Where(n => n.ChangeType == ChangeType.Added) + .Select(p => p.Item) + .ToArray(); + + this.Removed = changedItems + .Where(n => n.ChangeType == ChangeType.Removed) + .Select(p => p.Item) + .ToArray(); + + this.QuantityChanged = changedItems + .Where(n => n.ChangeType == ChangeType.StackChange) + .Select(change => new ItemStackSizeChange( + item: change.Item, + oldSize: change.Item.Stack - change.StackChange, + newSize: change.Item.Stack + )) + .ToArray(); + } + } +} diff --git a/src/SMAPI/Events/ItemStackSizeChange.cs b/src/SMAPI/Events/ItemStackSizeChange.cs new file mode 100644 index 00000000..35369be2 --- /dev/null +++ b/src/SMAPI/Events/ItemStackSizeChange.cs @@ -0,0 +1,35 @@ +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>An inventory item stack size change.</summary> + public class ItemStackSizeChange + { + /********* + ** Accessors + *********/ + /// <summary>The item whose stack size changed.</summary> + public Item Item { get; } + + /// <summary>The previous stack size.</summary> + public int OldSize { get; } + + /// <summary>The new stack size.</summary> + public int NewSize { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="item">The item whose stack size changed.</param> + /// <param name="oldSize">The previous stack size.</param> + /// <param name="newSize">The new stack size.</param> + public ItemStackSizeChange(Item item, int oldSize, int newSize) + { + this.Item = item; + this.OldSize = oldSize; + this.NewSize = newSize; + } + } +} diff --git a/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/LargeTerrainFeatureListChangedEventArgs.cs index 053a0e41..63b12687 100644 --- a/src/SMAPI/Events/WorldLargeTerrainFeatureListChangedEventArgs.cs +++ b/src/SMAPI/Events/LargeTerrainFeatureListChangedEventArgs.cs @@ -7,7 +7,7 @@ using StardewValley.TerrainFeatures; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.LargeTerrainFeatureListChanged"/> event.</summary> - public class WorldLargeTerrainFeatureListChangedEventArgs : EventArgs + public class LargeTerrainFeatureListChangedEventArgs : EventArgs { /********* ** Accessors @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The large terrain features added to the location.</param> /// <param name="removed">The large terrain features removed from the location.</param> - public WorldLargeTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<LargeTerrainFeature> added, IEnumerable<LargeTerrainFeature> removed) + public LargeTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<LargeTerrainFeature> added, IEnumerable<LargeTerrainFeature> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/LevelChangedEventArgs.cs b/src/SMAPI/Events/LevelChangedEventArgs.cs new file mode 100644 index 00000000..174094c7 --- /dev/null +++ b/src/SMAPI/Events/LevelChangedEventArgs.cs @@ -0,0 +1,42 @@ +using System; +using StardewModdingAPI.Enums; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for a <see cref="IPlayerEvents.LevelChanged"/> event.</summary> + public class LevelChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player whose skill level changed.</summary> + public Farmer Player { get; } + + /// <summary>The skill whose level changed.</summary> + public SkillType Skill { get; } + + /// <summary>The previous skill level.</summary> + public int OldLevel { get; } + + /// <summary>The new skill level.</summary> + public int NewLevel { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player whose skill level changed.</param> + /// <param name="skill">The skill whose level changed.</param> + /// <param name="oldLevel">The previous skill level.</param> + /// <param name="newLevel">The new skill level.</param> + public LevelChangedEventArgs(Farmer player, SkillType skill, int oldLevel, int newLevel) + { + this.Player = player; + this.Skill = skill; + this.OldLevel = oldLevel; + this.NewLevel = newLevel; + } + } +} diff --git a/src/SMAPI/Events/LocationEvents.cs b/src/SMAPI/Events/LocationEvents.cs index e2108de0..81f547ae 100644 --- a/src/SMAPI/Events/LocationEvents.cs +++ b/src/SMAPI/Events/LocationEvents.cs @@ -19,22 +19,22 @@ namespace StardewModdingAPI.Events /// <summary>Raised after a game location is added or removed.</summary> public static event EventHandler<EventArgsLocationsChanged> LocationsChanged { - add => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_LocationsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_LocationsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_LocationsChanged.Remove(value); } /// <summary>Raised after buildings are added or removed in a location.</summary> public static event EventHandler<EventArgsLocationBuildingsChanged> BuildingsChanged { - add => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_BuildingsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_BuildingsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_BuildingsChanged.Remove(value); } /// <summary>Raised after objects are added or removed in a location.</summary> public static event EventHandler<EventArgsLocationObjectsChanged> ObjectsChanged { - add => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Add(value); - remove => LocationEvents.EventManager.Legacy_Location_ObjectsChanged.Remove(value); + add => LocationEvents.EventManager.Legacy_ObjectsChanged.Add(value); + remove => LocationEvents.EventManager.Legacy_ObjectsChanged.Remove(value); } diff --git a/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs b/src/SMAPI/Events/LocationListChangedEventArgs.cs index 8bc26a43..e93f0a80 100644 --- a/src/SMAPI/Events/WorldLocationListChangedEventArgs.cs +++ b/src/SMAPI/Events/LocationListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.LocationListChanged"/> event.</summary> - public class WorldLocationListChangedEventArgs : EventArgs + public class LocationListChangedEventArgs : EventArgs { /********* ** Accessors @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Events /// <summary>Construct an instance.</summary> /// <param name="added">The added locations.</param> /// <param name="removed">The removed locations.</param> - public WorldLocationListChangedEventArgs(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed) + public LocationListChangedEventArgs(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed) { this.Added = added.ToArray(); this.Removed = removed.ToArray(); diff --git a/src/SMAPI/Events/MenuChangedEventArgs.cs b/src/SMAPI/Events/MenuChangedEventArgs.cs new file mode 100644 index 00000000..e1c049a2 --- /dev/null +++ b/src/SMAPI/Events/MenuChangedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using StardewValley.Menus; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.MenuChanged"/> event.</summary> + public class MenuChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous menu.</summary> + public IClickableMenu OldMenu { get; } + + /// <summary>The current menu.</summary> + public IClickableMenu NewMenu { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldMenu">The previous menu.</param> + /// <param name="newMenu">The current menu.</param> + public MenuChangedEventArgs(IClickableMenu oldMenu, IClickableMenu newMenu) + { + this.OldMenu = oldMenu; + this.NewMenu = newMenu; + } + } +} diff --git a/src/SMAPI/Events/MenuEvents.cs b/src/SMAPI/Events/MenuEvents.cs index 7fcc3844..362b5070 100644 --- a/src/SMAPI/Events/MenuEvents.cs +++ b/src/SMAPI/Events/MenuEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.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 { - add => MenuEvents.EventManager.Menu_Changed.Add(value); - remove => MenuEvents.EventManager.Menu_Changed.Remove(value); + add => MenuEvents.EventManager.Legacy_MenuChanged.Add(value); + remove => MenuEvents.EventManager.Legacy_MenuChanged.Remove(value); } /// <summary>Raised after a game menu is closed.</summary> public static event EventHandler<EventArgsClickableMenuClosed> MenuClosed { - add => MenuEvents.EventManager.Menu_Closed.Add(value); - remove => MenuEvents.EventManager.Menu_Closed.Remove(value); + add => MenuEvents.EventManager.Legacy_MenuClosed.Add(value); + remove => MenuEvents.EventManager.Legacy_MenuClosed.Remove(value); } diff --git a/src/SMAPI/Events/MineEvents.cs b/src/SMAPI/Events/MineEvents.cs index 5ee4001b..f5565a76 100644 --- a/src/SMAPI/Events/MineEvents.cs +++ b/src/SMAPI/Events/MineEvents.cs @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the player warps to a new level of the mine.</summary> public static event EventHandler<EventArgsMineLevelChanged> MineLevelChanged { - add => MineEvents.EventManager.Mine_LevelChanged.Add(value); - remove => MineEvents.EventManager.Mine_LevelChanged.Remove(value); + add => MineEvents.EventManager.Legacy_MineLevelChanged.Add(value); + remove => MineEvents.EventManager.Legacy_MineLevelChanged.Remove(value); } diff --git a/src/SMAPI/Events/ModMessageReceivedEventArgs.cs b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs new file mode 100644 index 00000000..49366ec6 --- /dev/null +++ b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs @@ -0,0 +1,46 @@ +using System; +using StardewModdingAPI.Framework.Networking; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.ModMessageReceived"/> event.</summary> + public class ModMessageReceivedEventArgs : EventArgs + { + /********* + ** Properties + *********/ + /// <summary>The underlying message model.</summary> + private readonly ModMessageModel Message; + + + /********* + ** Accessors + *********/ + /// <summary>The unique ID of the player from whose computer the message was sent.</summary> + public long FromPlayerID => this.Message.FromPlayerID; + + /// <summary>The unique ID of the mod which sent the message.</summary> + public string FromModID => this.Message.FromModID; + + /// <summary>A message type which can be used to decide whether it's the one you want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, so mods should check the <see cref="FromModID"/>.</summary> + public string Type => this.Message.Type; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="message">The received message.</param> + internal ModMessageReceivedEventArgs(ModMessageModel message) + { + this.Message = message; + } + + /// <summary>Read the message data into the given model type.</summary> + /// <typeparam name="TModel">The message model type.</typeparam> + public TModel ReadAs<TModel>() + { + return this.Message.Data.ToObject<TModel>(); + } + } +} diff --git a/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs b/src/SMAPI/Events/MouseWheelScrolledEventArgs.cs index 9afab9cc..3ab9d412 100644 --- a/src/SMAPI/Events/InputMouseWheelScrolledEventArgs.cs +++ b/src/SMAPI/Events/MouseWheelScrolledEventArgs.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Events { /// <summary>Event arguments when the player scrolls the mouse wheel.</summary> - public class InputMouseWheelScrolledEventArgs : EventArgs + public class MouseWheelScrolledEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="position">The cursor position.</param> /// <param name="oldValue">The old scroll value.</param> /// <param name="newValue">The new scroll value.</param> - public InputMouseWheelScrolledEventArgs(ICursorPosition position, int oldValue, int newValue) + public MouseWheelScrolledEventArgs(ICursorPosition position, int oldValue, int newValue) { this.Position = position; this.OldValue = oldValue; diff --git a/src/SMAPI/Events/MultiplayerEvents.cs b/src/SMAPI/Events/MultiplayerEvents.cs index f96ecba5..49de380e 100644 --- a/src/SMAPI/Events/MultiplayerEvents.cs +++ b/src/SMAPI/Events/MultiplayerEvents.cs @@ -19,29 +19,29 @@ namespace StardewModdingAPI.Events /// <summary>Raised before the game syncs changes from other players.</summary> public static event EventHandler BeforeMainSync { - add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainSync.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Remove(value); } /// <summary>Raised after the game syncs changes from other players.</summary> public static event EventHandler AfterMainSync { - add => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainSync.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_AfterMainSync.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_AfterMainSync.Remove(value); } /// <summary>Raised before the game broadcasts changes to other players.</summary> public static event EventHandler BeforeMainBroadcast { - add => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_BeforeMainBroadcast.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Remove(value); } /// <summary>Raised after the game broadcasts changes to other players.</summary> public static event EventHandler AfterMainBroadcast { - add => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Add(value); - remove => MultiplayerEvents.EventManager.Multiplayer_AfterMainBroadcast.Remove(value); + add => MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Add(value); + remove => MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Remove(value); } diff --git a/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs b/src/SMAPI/Events/NpcListChangedEventArgs.cs index e251f894..eca28244 100644 --- a/src/SMAPI/Events/WorldNpcListChangedEventArgs.cs +++ b/src/SMAPI/Events/NpcListChangedEventArgs.cs @@ -6,7 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.NpcListChanged"/> event.</summary> - public class WorldNpcListChangedEventArgs : EventArgs + public class NpcListChangedEventArgs : EventArgs { /********* ** Accessors @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The NPCs added to the location.</param> /// <param name="removed">The NPCs removed from the location.</param> - public WorldNpcListChangedEventArgs(GameLocation location, IEnumerable<NPC> added, IEnumerable<NPC> removed) + public NpcListChangedEventArgs(GameLocation location, IEnumerable<NPC> added, IEnumerable<NPC> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs b/src/SMAPI/Events/ObjectListChangedEventArgs.cs index 5623a49b..55a4034f 100644 --- a/src/SMAPI/Events/WorldObjectListChangedEventArgs.cs +++ b/src/SMAPI/Events/ObjectListChangedEventArgs.cs @@ -8,7 +8,7 @@ using Object = StardewValley.Object; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.ObjectListChanged"/> event.</summary> - public class WorldObjectListChangedEventArgs : EventArgs + public class ObjectListChangedEventArgs : EventArgs { /********* ** Accessors @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The objects added to the location.</param> /// <param name="removed">The objects removed from the location.</param> - public WorldObjectListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, Object>> added, IEnumerable<KeyValuePair<Vector2, Object>> removed) + public ObjectListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, Object>> added, IEnumerable<KeyValuePair<Vector2, Object>> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/PeerContextReceivedEventArgs.cs b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs new file mode 100644 index 00000000..151a295c --- /dev/null +++ b/src/SMAPI/Events/PeerContextReceivedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerContextReceived"/> event.</summary> + public class PeerContextReceivedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The peer whose metadata was received.</summary> + public IMultiplayerPeer Peer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="peer">The peer whose metadata was received.</param> + internal PeerContextReceivedEventArgs(IMultiplayerPeer peer) + { + this.Peer = peer; + } + } +} diff --git a/src/SMAPI/Events/PeerDisconnectedEventArgs.cs b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs new file mode 100644 index 00000000..8517988a --- /dev/null +++ b/src/SMAPI/Events/PeerDisconnectedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IMultiplayerEvents.PeerDisconnected"/> event.</summary> + public class PeerDisconnectedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The peer who disconnected.</summary> + public IMultiplayerPeer Peer { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="peer">The peer who disconnected.</param> + internal PeerDisconnectedEventArgs(IMultiplayerPeer peer) + { + this.Peer = peer; + } + } +} diff --git a/src/SMAPI/Events/PlayerEvents.cs b/src/SMAPI/Events/PlayerEvents.cs index 6e7050e3..bfc1b569 100644 --- a/src/SMAPI/Events/PlayerEvents.cs +++ b/src/SMAPI/Events/PlayerEvents.cs @@ -19,22 +19,22 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> public static event EventHandler<EventArgsInventoryChanged> InventoryChanged { - add => PlayerEvents.EventManager.Player_InventoryChanged.Add(value); - remove => PlayerEvents.EventManager.Player_InventoryChanged.Remove(value); + add => PlayerEvents.EventManager.Legacy_InventoryChanged.Add(value); + remove => PlayerEvents.EventManager.Legacy_InventoryChanged.Remove(value); } /// <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 { - add => PlayerEvents.EventManager.Player_LeveledUp.Add(value); - remove => PlayerEvents.EventManager.Player_LeveledUp.Remove(value); + add => PlayerEvents.EventManager.Legacy_LeveledUp.Add(value); + remove => PlayerEvents.EventManager.Legacy_LeveledUp.Remove(value); } /// <summary>Raised after the player warps to a new location.</summary> public static event EventHandler<EventArgsPlayerWarped> Warped { - add => PlayerEvents.EventManager.Player_Warped.Add(value); - remove => PlayerEvents.EventManager.Player_Warped.Remove(value); + add => PlayerEvents.EventManager.Legacy_PlayerWarped.Add(value); + remove => PlayerEvents.EventManager.Legacy_PlayerWarped.Remove(value); } diff --git a/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs b/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs new file mode 100644 index 00000000..efd4163b --- /dev/null +++ b/src/SMAPI/Events/RenderedActiveMenuEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedActiveMenu"/> event.</summary> + public class RenderedActiveMenuEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedEventArgs.cs b/src/SMAPI/Events/RenderedEventArgs.cs new file mode 100644 index 00000000..d6341b19 --- /dev/null +++ b/src/SMAPI/Events/RenderedEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.Rendered"/> event.</summary> + public class RenderedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedHudEventArgs.cs b/src/SMAPI/Events/RenderedHudEventArgs.cs new file mode 100644 index 00000000..46e89013 --- /dev/null +++ b/src/SMAPI/Events/RenderedHudEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedHud"/> event.</summary> + public class RenderedHudEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderedWorldEventArgs.cs b/src/SMAPI/Events/RenderedWorldEventArgs.cs new file mode 100644 index 00000000..56145381 --- /dev/null +++ b/src/SMAPI/Events/RenderedWorldEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderedWorld"/> event.</summary> + public class RenderedWorldEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs b/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs new file mode 100644 index 00000000..103f56df --- /dev/null +++ b/src/SMAPI/Events/RenderingActiveMenuEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingActiveMenu"/> event.</summary> + public class RenderingActiveMenuEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingEventArgs.cs b/src/SMAPI/Events/RenderingEventArgs.cs new file mode 100644 index 00000000..5acbef09 --- /dev/null +++ b/src/SMAPI/Events/RenderingEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.Rendering"/> event.</summary> + public class RenderingEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingHudEventArgs.cs b/src/SMAPI/Events/RenderingHudEventArgs.cs new file mode 100644 index 00000000..84c96ecd --- /dev/null +++ b/src/SMAPI/Events/RenderingHudEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingHud"/> event.</summary> + public class RenderingHudEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/RenderingWorldEventArgs.cs b/src/SMAPI/Events/RenderingWorldEventArgs.cs new file mode 100644 index 00000000..d0d44789 --- /dev/null +++ b/src/SMAPI/Events/RenderingWorldEventArgs.cs @@ -0,0 +1,16 @@ +using System; +using Microsoft.Xna.Framework.Graphics; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.RenderingWorld"/> event.</summary> + public class RenderingWorldEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The sprite batch being drawn. Add anything you want to appear on-screen to this sprite batch.</summary> + public SpriteBatch SpriteBatch => Game1.spriteBatch; + } +} diff --git a/src/SMAPI/Events/ReturnedToTitleEventArgs.cs b/src/SMAPI/Events/ReturnedToTitleEventArgs.cs new file mode 100644 index 00000000..96309cde --- /dev/null +++ b/src/SMAPI/Events/ReturnedToTitleEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.ReturnedToTitle"/> event.</summary> + public class ReturnedToTitleEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveCreatedEventArgs.cs b/src/SMAPI/Events/SaveCreatedEventArgs.cs new file mode 100644 index 00000000..5ae22531 --- /dev/null +++ b/src/SMAPI/Events/SaveCreatedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveCreated"/> event.</summary> + public class SaveCreatedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveCreatingEventArgs.cs b/src/SMAPI/Events/SaveCreatingEventArgs.cs new file mode 100644 index 00000000..3c83f421 --- /dev/null +++ b/src/SMAPI/Events/SaveCreatingEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveCreating"/> event.</summary> + public class SaveCreatingEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SaveEvents.cs b/src/SMAPI/Events/SaveEvents.cs index 62184282..731bf9d1 100644 --- a/src/SMAPI/Events/SaveEvents.cs +++ b/src/SMAPI/Events/SaveEvents.cs @@ -19,43 +19,43 @@ namespace StardewModdingAPI.Events /// <summary>Raised before the game creates the save file.</summary> public static event EventHandler BeforeCreate { - add => SaveEvents.EventManager.Save_BeforeCreate.Add(value); - remove => SaveEvents.EventManager.Save_BeforeCreate.Remove(value); + add => SaveEvents.EventManager.Legacy_BeforeCreateSave.Add(value); + remove => SaveEvents.EventManager.Legacy_BeforeCreateSave.Remove(value); } /// <summary>Raised after the game finishes creating the save file.</summary> public static event EventHandler AfterCreate { - add => SaveEvents.EventManager.Save_AfterCreate.Add(value); - remove => SaveEvents.EventManager.Save_AfterCreate.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterCreateSave.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterCreateSave.Remove(value); } /// <summary>Raised before the game begins writes data to the save file.</summary> public static event EventHandler BeforeSave { - add => SaveEvents.EventManager.Save_BeforeSave.Add(value); - remove => SaveEvents.EventManager.Save_BeforeSave.Remove(value); + add => SaveEvents.EventManager.Legacy_BeforeSave.Add(value); + remove => SaveEvents.EventManager.Legacy_BeforeSave.Remove(value); } /// <summary>Raised after the game finishes writing data to the save file.</summary> public static event EventHandler AfterSave { - add => SaveEvents.EventManager.Save_AfterSave.Add(value); - remove => SaveEvents.EventManager.Save_AfterSave.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterSave.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterSave.Remove(value); } /// <summary>Raised after the player loads a save slot.</summary> public static event EventHandler AfterLoad { - add => SaveEvents.EventManager.Save_AfterLoad.Add(value); - remove => SaveEvents.EventManager.Save_AfterLoad.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterLoad.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterLoad.Remove(value); } /// <summary>Raised after the game returns to the title screen.</summary> public static event EventHandler AfterReturnToTitle { - add => SaveEvents.EventManager.Save_AfterReturnToTitle.Add(value); - remove => SaveEvents.EventManager.Save_AfterReturnToTitle.Remove(value); + add => SaveEvents.EventManager.Legacy_AfterReturnToTitle.Add(value); + remove => SaveEvents.EventManager.Legacy_AfterReturnToTitle.Remove(value); } diff --git a/src/SMAPI/Events/SaveLoadedEventArgs.cs b/src/SMAPI/Events/SaveLoadedEventArgs.cs new file mode 100644 index 00000000..f8aaa7f7 --- /dev/null +++ b/src/SMAPI/Events/SaveLoadedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.SaveLoaded"/> event.</summary> + public class SaveLoadedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SavedEventArgs.cs b/src/SMAPI/Events/SavedEventArgs.cs new file mode 100644 index 00000000..a4e90729 --- /dev/null +++ b/src/SMAPI/Events/SavedEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.Saved"/> event.</summary> + public class SavedEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SavingEventArgs.cs b/src/SMAPI/Events/SavingEventArgs.cs new file mode 100644 index 00000000..f323ca9e --- /dev/null +++ b/src/SMAPI/Events/SavingEventArgs.cs @@ -0,0 +1,7 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.Saving"/> event.</summary> + public class SavingEventArgs : EventArgs { } +} diff --git a/src/SMAPI/Events/SpecialisedEvents.cs b/src/SMAPI/Events/SpecialisedEvents.cs index 33ebf3b2..bdf25ccb 100644 --- a/src/SMAPI/Events/SpecialisedEvents.cs +++ b/src/SMAPI/Events/SpecialisedEvents.cs @@ -3,7 +3,7 @@ using StardewModdingAPI.Framework.Events; namespace StardewModdingAPI.Events { - /// <summary>Events serving specialised edge cases that shouldn't be used by most mod.</summary> + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> public static class SpecialisedEvents { /********* @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Events /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> public static event EventHandler UnvalidatedUpdateTick { - add => SpecialisedEvents.EventManager.Specialised_UnvalidatedUpdateTick.Add(value); - remove => SpecialisedEvents.EventManager.Specialised_UnvalidatedUpdateTick.Remove(value); + add => SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Add(value); + remove => SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Remove(value); } diff --git a/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs b/src/SMAPI/Events/TerrainFeatureListChangedEventArgs.cs index cb089811..562b1d3c 100644 --- a/src/SMAPI/Events/WorldTerrainFeatureListChangedEventArgs.cs +++ b/src/SMAPI/Events/TerrainFeatureListChangedEventArgs.cs @@ -8,7 +8,7 @@ using StardewValley.TerrainFeatures; namespace StardewModdingAPI.Events { /// <summary>Event arguments for a <see cref="IWorldEvents.TerrainFeatureListChanged"/> event.</summary> - public class WorldTerrainFeatureListChangedEventArgs : EventArgs + public class TerrainFeatureListChangedEventArgs : EventArgs { /********* ** Accessors @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Events /// <param name="location">The location which changed.</param> /// <param name="added">The terrain features added to the location.</param> /// <param name="removed">The terrain features removed from the location.</param> - public WorldTerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> added, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> removed) + public TerrainFeatureListChangedEventArgs(GameLocation location, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> added, IEnumerable<KeyValuePair<Vector2, TerrainFeature>> removed) { this.Location = location; this.Added = added.ToArray(); diff --git a/src/SMAPI/Events/TimeChangedEventArgs.cs b/src/SMAPI/Events/TimeChangedEventArgs.cs new file mode 100644 index 00000000..fd472092 --- /dev/null +++ b/src/SMAPI/Events/TimeChangedEventArgs.cs @@ -0,0 +1,30 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IGameLoopEvents.TimeChanged"/> event.</summary> + public class TimeChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous time of day in 24-hour notation (like 1600 for 4pm). The clock time resets when the player sleeps, so 2am (before sleeping) is 2600.</summary> + public int OldTime { get; } + + /// <summary>The current time of day in 24-hour notation (like 1600 for 4pm). The clock time resets when the player sleeps, so 2am (before sleeping) is 2600.</summary> + public int NewTime { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldTime">The previous time of day in 24-hour notation (like 1600 for 4pm).</param> + /// <param name="newTime">The current time of day in 24-hour notation (like 1600 for 4pm).</param> + public TimeChangedEventArgs(int oldTime, int newTime) + { + this.OldTime = oldTime; + this.NewTime = newTime; + } + } +} diff --git a/src/SMAPI/Events/TimeEvents.cs b/src/SMAPI/Events/TimeEvents.cs index f769fd08..311ffe9e 100644 --- a/src/SMAPI/Events/TimeEvents.cs +++ b/src/SMAPI/Events/TimeEvents.cs @@ -19,15 +19,15 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game begins a new day, including when loading a save.</summary> public static event EventHandler AfterDayStarted { - add => TimeEvents.EventManager.Time_AfterDayStarted.Add(value); - remove => TimeEvents.EventManager.Time_AfterDayStarted.Remove(value); + add => TimeEvents.EventManager.Legacy_AfterDayStarted.Add(value); + remove => TimeEvents.EventManager.Legacy_AfterDayStarted.Remove(value); } /// <summary>Raised after the in-game clock changes.</summary> public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged { - add => TimeEvents.EventManager.Time_TimeOfDayChanged.Add(value); - remove => TimeEvents.EventManager.Time_TimeOfDayChanged.Remove(value); + add => TimeEvents.EventManager.Legacy_TimeOfDayChanged.Add(value); + remove => TimeEvents.EventManager.Legacy_TimeOfDayChanged.Remove(value); } diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs new file mode 100644 index 00000000..5638bdb7 --- /dev/null +++ b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs @@ -0,0 +1,36 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/> event.</summary> + public class UnvalidatedUpdateTickedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> + public uint Ticks { get; } + + /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> + public bool IsOneSecond { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> + public UnvalidatedUpdateTickedEventArgs(uint ticks) + { + this.Ticks = ticks; + this.IsOneSecond = this.IsMultipleOf(60); + } + + /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary> + /// <param name="number">The factor to check.</param> + public bool IsMultipleOf(uint number) + { + return this.Ticks % number == 0; + } + } +} diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs new file mode 100644 index 00000000..ebadbb99 --- /dev/null +++ b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs @@ -0,0 +1,36 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/> event.</summary> + public class UnvalidatedUpdateTickingEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The number of ticks elapsed since the game started, including the current tick.</summary> + public uint Ticks { get; } + + /// <summary>Whether <see cref="Ticks"/> is a multiple of 60, which happens approximately once per second.</summary> + public bool IsOneSecond { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> + public UnvalidatedUpdateTickingEventArgs(uint ticks) + { + this.Ticks = ticks; + this.IsOneSecond = this.IsMultipleOf(60); + } + + /// <summary>Get whether <see cref="Ticks"/> is a multiple of the given <paramref name="number"/>. This is mainly useful if you want to run logic intermittently (e.g. <code>e.IsMultipleOf(30)</code> for every half-second).</summary> + /// <param name="number">The factor to check.</param> + public bool IsMultipleOf(uint number) + { + return this.Ticks % number == 0; + } + } +} diff --git a/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs b/src/SMAPI/Events/UpdateTickedEventArgs.cs index 3ad34b69..56912643 100644 --- a/src/SMAPI/Events/GameLoopUpdatedEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickedEventArgs.cs @@ -2,8 +2,8 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updated"/> event.</summary> - public class GameLoopUpdatedEventArgs : EventArgs + /// <summary>Event arguments for an <see cref="IGameLoopEvents.UpdateTicked"/> event.</summary> + public class UpdateTickedEventArgs : EventArgs { /********* ** Accessors @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Events *********/ /// <summary>Construct an instance.</summary> /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> - public GameLoopUpdatedEventArgs(uint ticks) + public UpdateTickedEventArgs(uint ticks) { this.Ticks = ticks; this.IsOneSecond = this.IsMultipleOf(60); diff --git a/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs b/src/SMAPI/Events/UpdateTickingEventArgs.cs index d6a8b5c2..5998fd9b 100644 --- a/src/SMAPI/Events/GameLoopUpdatingEventArgs.cs +++ b/src/SMAPI/Events/UpdateTickingEventArgs.cs @@ -2,8 +2,8 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="IGameLoopEvents.Updating"/> event.</summary> - public class GameLoopUpdatingEventArgs : EventArgs + /// <summary>Event arguments for an <see cref="IGameLoopEvents.UpdateTicking"/> event.</summary> + public class UpdateTickingEventArgs : EventArgs { /********* ** Accessors @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Events *********/ /// <summary>Construct an instance.</summary> /// <param name="ticks">The number of ticks elapsed since the game started, including the current tick.</param> - public GameLoopUpdatingEventArgs(uint ticks) + public UpdateTickingEventArgs(uint ticks) { this.Ticks = ticks; this.IsOneSecond = this.IsMultipleOf(60); diff --git a/src/SMAPI/Events/WarpedEventArgs.cs b/src/SMAPI/Events/WarpedEventArgs.cs new file mode 100644 index 00000000..1b1c7381 --- /dev/null +++ b/src/SMAPI/Events/WarpedEventArgs.cs @@ -0,0 +1,37 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IPlayerEvents.Warped"/> event.</summary> + public class WarpedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The player who warped to a new location.</summary> + public Farmer Player { get; } + + /// <summary>The player's previous location.</summary> + public GameLocation OldLocation { get; } + + /// <summary>The player's current location.</summary> + public GameLocation NewLocation { get; } + + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player who warped to a new location.</param> + /// <param name="oldLocation">The player's previous location.</param> + /// <param name="newLocation">The player's current location.</param> + public WarpedEventArgs(Farmer player, GameLocation oldLocation, GameLocation newLocation) + { + this.Player = player; + this.NewLocation = newLocation; + this.OldLocation = oldLocation; + } + } +} diff --git a/src/SMAPI/Events/WindowResizedEventArgs.cs b/src/SMAPI/Events/WindowResizedEventArgs.cs new file mode 100644 index 00000000..a990ba9d --- /dev/null +++ b/src/SMAPI/Events/WindowResizedEventArgs.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.Xna.Framework; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IDisplayEvents.WindowResized"/> event.</summary> + public class WindowResizedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous window size.</summary> + public Point OldSize { get; } + + /// <summary>The current window size.</summary> + public Point NewSize { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldSize">The previous window size.</param> + /// <param name="newSize">The current window size.</param> + public WindowResizedEventArgs(Point oldSize, Point newSize) + { + this.OldSize = oldSize; + this.NewSize = newSize; + } + } +} diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs index 943e018d..8c9df47d 100644 --- a/src/SMAPI/Framework/Command.cs +++ b/src/SMAPI/Framework/Command.cs @@ -1,4 +1,4 @@ -using System; +using System; namespace StardewModdingAPI.Framework { @@ -8,8 +8,8 @@ namespace StardewModdingAPI.Framework /********* ** Accessor *********/ - /// <summary>The friendly name for the mod that registered the command.</summary> - public string ModName { get; } + /// <summary>The mod that registered the command (or <c>null</c> if registered by SMAPI).</summary> + public IModMetadata Mod { get; } /// <summary>The command name, which the user must type to trigger it.</summary> public string Name { get; } @@ -25,13 +25,13 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modName">The friendly name for the mod that registered the command.</param> + /// <param name="mod">The mod that registered the command (or <c>null</c> if registered by SMAPI).</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) + public Command(IModMetadata mod, string name, string documentation, Action<string, string[]> callback) { - this.ModName = modName; + this.Mod = mod; this.Name = name; this.Documentation = documentation; this.Callback = callback; diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index f9651ed9..aabe99c3 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -19,7 +19,7 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Add a console command.</summary> - /// <param name="modName">The friendly mod name for this instance.</param> + /// <param name="mod">The mod adding the command (or <c>null</c> for a SMAPI 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> @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework /// <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) + public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { name = this.GetNormalisedName(name); @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework 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)); + this.Commands.Add(name, new Command(mod, name, documentation, callback)); } /// <summary>Get a command by its unique name.</summary> @@ -65,19 +65,30 @@ namespace StardewModdingAPI.Framework .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) + /// <summary>Try to parse a raw line of user input into an executable command.</summary> + /// <param name="input">The raw user input.</param> + /// <param name="name">The parsed command name.</param> + /// <param name="args">The parsed command arguments.</param> + /// <param name="command">The command which can handle the input.</param> + /// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns> + public bool TryParse(string input, out string name, out string[] args, out Command command) { + // ignore if blank if (string.IsNullOrWhiteSpace(input)) + { + name = null; + args = null; + command = null; return false; + } - string[] args = this.ParseArgs(input); - string name = args[0]; + // parse input + args = this.ParseArgs(input); + name = this.GetNormalisedName(args[0]); args = args.Skip(1).ToArray(); - return this.Trigger(name, args); + // get command + return this.Commands.TryGetValue(name, out command); } /// <summary>Trigger a command.</summary> diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 5c7b87de..f970762a 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -8,6 +8,14 @@ namespace StardewModdingAPI.Framework.Content internal class AssetDataForImage : AssetData<Texture2D>, IAssetDataForImage { /********* + ** Properties + *********/ + /// <summary>The minimum value to consider non-transparent.</summary> + /// <remarks>On Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason.</remarks> + private const byte MinOpacity = 5; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -53,13 +61,40 @@ namespace StardewModdingAPI.Framework.Content // merge data in overlay mode if (patchMode == PatchMode.Overlay) { + // get target data + Color[] targetData = new Color[pixelCount]; + target.GetData(0, targetArea, targetData, 0, pixelCount); + + // merge pixels 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 > 4) // not transparent (note: on Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason) - newData[i] = pixel; + Color above = sourceData[i]; + Color below = targetData[i]; + + // shortcut transparency + if (above.A < AssetDataForImage.MinOpacity) + continue; + if (below.A < AssetDataForImage.MinOpacity) + { + newData[i] = above; + continue; + } + + // merge pixels + // This performs a conventional alpha blend for the pixels, which are already + // premultiplied by the content pipeline. The formula is derived from + // https://blogs.msdn.microsoft.com/shawnhar/2009/11/06/premultiplied-alpha/. + // Note: don't use named arguments here since they're different between + // Linux/Mac and Windows. + float alphaBelow = 1 - (above.A / 255f); + newData[i] = new Color( + (int)(above.R + (below.R * alphaBelow)), // r + (int)(above.G + (below.G * alphaBelow)), // g + (int)(above.B + (below.B * alphaBelow)), // b + Math.Max(above.A, below.A) // a + ); } sourceData = newData; } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 24ce69ea..ed76a925 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -165,63 +165,25 @@ namespace StardewModdingAPI.Framework.ContentManagers return file; } - /// <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> + /// <summary>Premultiply a texture's alpha values to avoid transparency issues in the game.</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> + /// <remarks>Based on <a href="https://gamedev.stackexchange.com/a/26037">code by David Gouveia</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); - } - + // Textures loaded by Texture2D.FromStream are already premultiplied on Linux/Mac, even + // though the XNA documentation explicitly says otherwise. That's a glitch in MonoGame + // fixed in newer versions, but the game uses a bundled version that will always be + // affected. See https://github.com/MonoGame/MonoGame/issues/4820 for more info. + if (Constants.TargetPlatform != GamePlatform.Windows) + return texture; + + // premultiply pixels + Color[] data = new Color[texture.Width * texture.Height]; + texture.GetData(data); + for (int i = 0; i < data.Length; i++) + data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + texture.SetData(data); return texture; } } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index 62d8b80d..49285388 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -51,14 +51,32 @@ namespace StardewModdingAPI.Framework /// <typeparam name="TModel">The model type.</typeparam> /// <param name="path">The file path relative to the contnet directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public TModel ReadJsonFile<TModel>(string path) where TModel : class { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.ReadJsonFile)} with a relative path."); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model) ? model : null; } + /// <summary>Save data to a JSON file in the content pack's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.WriteJsonFile)} with a relative path."); + + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + this.JsonHelper.WriteJsonFile(path, data); + } + /// <summary>Load content from the content pack 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="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The local path to a content file relative to the content pack folder.</param> diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index 7a824a05..0fde67ee 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace StardewModdingAPI.Framework { @@ -18,6 +19,9 @@ namespace StardewModdingAPI.Framework /// <summary>Tracks the installed mods.</summary> private readonly ModRegistry ModRegistry; + /// <summary>The queued deprecation warnings to display.</summary> + private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); + /********* ** Public methods @@ -51,29 +55,40 @@ namespace StardewModdingAPI.Framework if (!this.MarkWarned(source ?? "<unknown>", nounPhrase, version)) return; - // build message - string message = $"{source ?? "An unknown mod"} uses deprecated code ({nounPhrase} is deprecated since SMAPI {version})."; - if (source == null) - message += $"{Environment.NewLine}{Environment.StackTrace}"; + // queue warning + this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity)); + } - // log message - switch (severity) + /// <summary>Print any queued messages.</summary> + public void PrintQueued() + { + foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { - case DeprecationLevel.Notice: - this.Monitor.Log(message, LogLevel.Trace); - break; + // build message + string message = $"{warning.ModName ?? "An unknown mod"} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; + if (warning.ModName == null) + message += $"{Environment.NewLine}{Environment.StackTrace}"; + + // log message + switch (warning.Level) + { + case DeprecationLevel.Notice: + this.Monitor.Log(message, LogLevel.Trace); + break; - case DeprecationLevel.Info: - this.Monitor.Log(message, LogLevel.Debug); - break; + case DeprecationLevel.Info: + this.Monitor.Log(message, LogLevel.Debug); + break; - case DeprecationLevel.PendingRemoval: - this.Monitor.Log(message, LogLevel.Warn); - break; + case DeprecationLevel.PendingRemoval: + this.Monitor.Log(message, LogLevel.Warn); + break; - default: - throw new NotSupportedException($"Unknown deprecation level '{severity}'"); + default: + throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'."); + } } + this.QueuedWarnings.Clear(); } /// <summary>Mark a deprecation warning as already logged.</summary> diff --git a/src/SMAPI/Framework/DeprecationWarning.cs b/src/SMAPI/Framework/DeprecationWarning.cs new file mode 100644 index 00000000..25415012 --- /dev/null +++ b/src/SMAPI/Framework/DeprecationWarning.cs @@ -0,0 +1,38 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>A deprecation warning for a mod.</summary> + internal class DeprecationWarning + { + /********* + ** Accessors + *********/ + /// <summary>The affected mod's display name.</summary> + public string ModName { get; } + + /// <summary>A noun phrase describing what is deprecated.</summary> + public string NounPhrase { get; } + + /// <summary>The SMAPI version which deprecated it.</summary> + public string Version { get; } + + /// <summary>The deprecation level for the affected code.</summary> + public DeprecationLevel Level { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The affected mod's display name.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="level">The deprecation level for the affected code.</param> + public DeprecationWarning(string modName, string nounPhrase, string version, DeprecationLevel level) + { + this.ModName = modName; + this.NounPhrase = nounPhrase; + this.Version = version; + this.Level = level; + } + } +} diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 168ddde0..b9d1c453 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -12,55 +12,148 @@ namespace StardewModdingAPI.Framework.Events ** Events (new) *********/ /**** + ** Display + ****/ + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + public readonly ManagedEvent<MenuChangedEventArgs> MenuChanged; + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public readonly ManagedEvent<RenderingEventArgs> Rendering; + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + public readonly ManagedEvent<RenderedEventArgs> Rendered; + + /// <summary>Raised before the game world is drawn to the screen.</summary> + public readonly ManagedEvent<RenderingWorldEventArgs> RenderingWorld; + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedWorldEventArgs> RenderedWorld; + + /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen.</summary> + public readonly ManagedEvent<RenderingActiveMenuEventArgs> RenderingActiveMenu; + + /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedActiveMenuEventArgs> RenderedActiveMenu; + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen.</summary> + public readonly ManagedEvent<RenderingHudEventArgs> RenderingHud; + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen.</summary> + public readonly ManagedEvent<RenderedHudEventArgs> RenderedHud; + + /// <summary>Raised after the game window is resized.</summary> + public readonly ManagedEvent<WindowResizedEventArgs> WindowResized; + + /**** ** Game loop ****/ /// <summary>Raised after the game is launched, right before the first update tick.</summary> - public readonly ManagedEvent<GameLoopLaunchedEventArgs> GameLoop_Launched; + public readonly ManagedEvent<GameLaunchedEventArgs> GameLaunched; /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - public readonly ManagedEvent<GameLoopUpdatingEventArgs> GameLoop_Updating; + public readonly ManagedEvent<UpdateTickingEventArgs> UpdateTicking; /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - public readonly ManagedEvent<GameLoopUpdatedEventArgs> GameLoop_Updated; + public readonly ManagedEvent<UpdateTickedEventArgs> UpdateTicked; + + /// <summary>Raised before the game creates the save file.</summary> + public readonly ManagedEvent<SaveCreatingEventArgs> SaveCreating; + + /// <summary>Raised after the game finishes creating the save file.</summary> + public readonly ManagedEvent<SaveCreatedEventArgs> SaveCreated; + + /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + public readonly ManagedEvent<SavingEventArgs> Saving; + + /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> + public readonly ManagedEvent<SavedEventArgs> Saved; + + /// <summary>Raised after the player loads a save slot.</summary> + public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded; + + /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + public readonly ManagedEvent<DayStartedEventArgs> DayStarted; + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary> + public readonly ManagedEvent<DayEndingEventArgs> DayEnding; + + /// <summary>Raised after the in-game clock time changes.</summary> + public readonly ManagedEvent<TimeChangedEventArgs> TimeChanged; + + /// <summary>Raised after the game returns to the title screen.</summary> + public readonly ManagedEvent<ReturnedToTitleEventArgs> ReturnedToTitle; /**** ** Input ****/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<InputButtonPressedEventArgs> Input_ButtonPressed; + public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed; /// <summary>Raised after the player released a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<InputButtonReleasedEventArgs> Input_ButtonReleased; + public readonly ManagedEvent<ButtonReleasedEventArgs> ButtonReleased; /// <summary>Raised after the player moves the in-game cursor.</summary> - public readonly ManagedEvent<InputCursorMovedEventArgs> Input_CursorMoved; + public readonly ManagedEvent<CursorMovedEventArgs> CursorMoved; /// <summary>Raised after the player scrolls the mouse wheel.</summary> - public readonly ManagedEvent<InputMouseWheelScrolledEventArgs> Input_MouseWheelScrolled; + public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled; + + /**** + ** Multiplayer + ****/ + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived; + + /// <summary>Raised after a mod message is received over the network.</summary> + public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived; + + /// <summary>Raised after the connection with a peer is severed.</summary> + public readonly ManagedEvent<PeerDisconnectedEventArgs> PeerDisconnected; + + /**** + ** Player + ****/ + /// <summary>Raised after items are added or removed to a player's inventory.</summary> + public readonly ManagedEvent<InventoryChangedEventArgs> InventoryChanged; + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> + public readonly ManagedEvent<LevelChangedEventArgs> LevelChanged; + + /// <summary>Raised after a player warps to a new location.</summary> + public readonly ManagedEvent<WarpedEventArgs> Warped; /**** ** World ****/ /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<WorldLocationListChangedEventArgs> World_LocationListChanged; + public readonly ManagedEvent<LocationListChangedEventArgs> LocationListChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<WorldBuildingListChangedEventArgs> World_BuildingListChanged; + public readonly ManagedEvent<BuildingListChangedEventArgs> BuildingListChanged; /// <summary>Raised after debris are added or removed in a location.</summary> - public readonly ManagedEvent<WorldDebrisListChangedEventArgs> World_DebrisListChanged; + public readonly ManagedEvent<DebrisListChangedEventArgs> DebrisListChanged; /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - public readonly ManagedEvent<WorldLargeTerrainFeatureListChangedEventArgs> World_LargeTerrainFeatureListChanged; + public readonly ManagedEvent<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; /// <summary>Raised after NPCs are added or removed in a location.</summary> - public readonly ManagedEvent<WorldNpcListChangedEventArgs> World_NpcListChanged; + public readonly ManagedEvent<NpcListChangedEventArgs> NpcListChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<WorldObjectListChangedEventArgs> World_ObjectListChanged; + public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged; /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - public readonly ManagedEvent<WorldTerrainFeatureListChangedEventArgs> World_TerrainFeatureListChanged; + public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; + + /**** + ** Specialised + ****/ + /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/>.</summary> + public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; + + /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/>.</summary> + public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; /********* @@ -70,185 +163,185 @@ namespace StardewModdingAPI.Framework.Events ** ContentEvents ****/ /// <summary>Raised after the content language changes.</summary> - public readonly ManagedEvent<EventArgsValueChanged<string>> Content_LocaleChanged; + public readonly ManagedEvent<EventArgsValueChanged<string>> Legacy_LocaleChanged; /**** ** ControlEvents ****/ /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> - public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_Control_KeyboardChanged; + public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_KeyboardChanged; /// <summary>Raised after the player presses a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyPressed; + public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyPressed; /// <summary>Raised after the player releases a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_Control_KeyReleased; + public readonly ManagedEvent<EventArgsKeyPressed> Legacy_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 readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_Control_MouseChanged; + public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_MouseChanged; /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_Control_ControllerButtonPressed; + public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_ControllerButtonPressed; /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_Control_ControllerButtonReleased; + public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_ControllerButtonReleased; /// <summary>The player pressed a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_Control_ControllerTriggerPressed; + public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_ControllerTriggerPressed; /// <summary>The player released a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_Control_ControllerTriggerReleased; + public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_ControllerTriggerReleased; /**** ** GameEvents ****/ /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> - public readonly ManagedEvent Game_FirstUpdateTick; + public readonly ManagedEvent Legacy_FirstUpdateTick; /// <summary>Raised when the game updates its state (≈60 times per second).</summary> - public readonly ManagedEvent Game_UpdateTick; + public readonly ManagedEvent Legacy_UpdateTick; /// <summary>Raised every other tick (≈30 times per second).</summary> - public readonly ManagedEvent Game_SecondUpdateTick; + public readonly ManagedEvent Legacy_SecondUpdateTick; /// <summary>Raised every fourth tick (≈15 times per second).</summary> - public readonly ManagedEvent Game_FourthUpdateTick; + public readonly ManagedEvent Legacy_FourthUpdateTick; /// <summary>Raised every eighth tick (≈8 times per second).</summary> - public readonly ManagedEvent Game_EighthUpdateTick; + public readonly ManagedEvent Legacy_EighthUpdateTick; /// <summary>Raised every 15th tick (≈4 times per second).</summary> - public readonly ManagedEvent Game_QuarterSecondTick; + public readonly ManagedEvent Legacy_QuarterSecondTick; /// <summary>Raised every 30th tick (≈twice per second).</summary> - public readonly ManagedEvent Game_HalfSecondTick; + public readonly ManagedEvent Legacy_HalfSecondTick; /// <summary>Raised every 60th tick (≈once per second).</summary> - public readonly ManagedEvent Game_OneSecondTick; + public readonly ManagedEvent Legacy_OneSecondTick; /**** ** GraphicsEvents ****/ /// <summary>Raised after the game window is resized.</summary> - public readonly ManagedEvent Graphics_Resize; + public readonly ManagedEvent Legacy_Resize; /// <summary>Raised before drawing the world to the screen.</summary> - public readonly ManagedEvent Graphics_OnPreRenderEvent; + public readonly ManagedEvent Legacy_OnPreRenderEvent; /// <summary>Raised after drawing the world to the screen.</summary> - public readonly ManagedEvent Graphics_OnPostRenderEvent; + public readonly ManagedEvent Legacy_OnPostRenderEvent; /// <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 readonly ManagedEvent Graphics_OnPreRenderHudEvent; + public readonly ManagedEvent Legacy_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 readonly ManagedEvent Graphics_OnPostRenderHudEvent; + public readonly ManagedEvent Legacy_OnPostRenderHudEvent; /// <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 readonly ManagedEvent Graphics_OnPreRenderGuiEvent; + public readonly ManagedEvent Legacy_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 readonly ManagedEvent Graphics_OnPostRenderGuiEvent; + public readonly ManagedEvent Legacy_OnPostRenderGuiEvent; /**** ** InputEvents ****/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonPressed; + public readonly ManagedEvent<EventArgsInput> Legacy_ButtonPressed; /// <summary>Raised after the player releases a keyboard key on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_Input_ButtonReleased; + public readonly ManagedEvent<EventArgsInput> Legacy_ButtonReleased; /**** ** LocationEvents ****/ /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_Location_LocationsChanged; + public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_LocationsChanged; /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_Location_BuildingsChanged; + public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_BuildingsChanged; /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_Location_ObjectsChanged; + public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_ObjectsChanged; /**** ** MenuEvents ****/ /// <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 readonly ManagedEvent<EventArgsClickableMenuChanged> Menu_Changed; + public readonly ManagedEvent<EventArgsClickableMenuChanged> Legacy_MenuChanged; /// <summary>Raised after a game menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuClosed> Menu_Closed; + public readonly ManagedEvent<EventArgsClickableMenuClosed> Legacy_MenuClosed; /**** ** MultiplayerEvents ****/ /// <summary>Raised before the game syncs changes from other players.</summary> - public readonly ManagedEvent Multiplayer_BeforeMainSync; + public readonly ManagedEvent Legacy_BeforeMainSync; /// <summary>Raised after the game syncs changes from other players.</summary> - public readonly ManagedEvent Multiplayer_AfterMainSync; + public readonly ManagedEvent Legacy_AfterMainSync; /// <summary>Raised before the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Multiplayer_BeforeMainBroadcast; + public readonly ManagedEvent Legacy_BeforeMainBroadcast; /// <summary>Raised after the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Multiplayer_AfterMainBroadcast; + public readonly ManagedEvent Legacy_AfterMainBroadcast; /**** ** MineEvents ****/ /// <summary>Raised after the player warps to a new level of the mine.</summary> - public readonly ManagedEvent<EventArgsMineLevelChanged> Mine_LevelChanged; + public readonly ManagedEvent<EventArgsMineLevelChanged> Legacy_MineLevelChanged; /**** ** PlayerEvents ****/ /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> - public readonly ManagedEvent<EventArgsInventoryChanged> Player_InventoryChanged; + public readonly ManagedEvent<EventArgsInventoryChanged> Legacy_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 readonly ManagedEvent<EventArgsLevelUp> Player_LeveledUp; + public readonly ManagedEvent<EventArgsLevelUp> Legacy_LeveledUp; /// <summary>Raised after the player warps to a new location.</summary> - public readonly ManagedEvent<EventArgsPlayerWarped> Player_Warped; + public readonly ManagedEvent<EventArgsPlayerWarped> Legacy_PlayerWarped; /**** ** SaveEvents ****/ /// <summary>Raised before the game creates the save file.</summary> - public readonly ManagedEvent Save_BeforeCreate; + public readonly ManagedEvent Legacy_BeforeCreateSave; /// <summary>Raised after the game finishes creating the save file.</summary> - public readonly ManagedEvent Save_AfterCreate; + public readonly ManagedEvent Legacy_AfterCreateSave; /// <summary>Raised before the game begins writes data to the save file.</summary> - public readonly ManagedEvent Save_BeforeSave; + public readonly ManagedEvent Legacy_BeforeSave; /// <summary>Raised after the game finishes writing data to the save file.</summary> - public readonly ManagedEvent Save_AfterSave; + public readonly ManagedEvent Legacy_AfterSave; /// <summary>Raised after the player loads a save slot.</summary> - public readonly ManagedEvent Save_AfterLoad; + public readonly ManagedEvent Legacy_AfterLoad; /// <summary>Raised after the game returns to the title screen.</summary> - public readonly ManagedEvent Save_AfterReturnToTitle; + public readonly ManagedEvent Legacy_AfterReturnToTitle; /**** ** SpecialisedEvents ****/ /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> - public readonly ManagedEvent Specialised_UnvalidatedUpdateTick; + public readonly ManagedEvent Legacy_UnvalidatedUpdateTick; /**** ** TimeEvents ****/ /// <summary>Raised after the game begins a new day, including when loading a save.</summary> - public readonly ManagedEvent Time_AfterDayStarted; + public readonly ManagedEvent Legacy_AfterDayStarted; /// <summary>Raised after the in-game clock changes.</summary> - public readonly ManagedEvent<EventArgsIntChanged> Time_TimeOfDayChanged; + public readonly ManagedEvent<EventArgsIntChanged> Legacy_TimeOfDayChanged; /********* @@ -264,84 +357,115 @@ namespace StardewModdingAPI.Framework.Events ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry); // init events (new) - this.GameLoop_Launched = ManageEventOf<GameLoopLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Launched)); - this.GameLoop_Updating = ManageEventOf<GameLoopUpdatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updating)); - this.GameLoop_Updated = ManageEventOf<GameLoopUpdatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Updated)); - - this.Input_ButtonPressed = ManageEventOf<InputButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); - this.Input_ButtonReleased = ManageEventOf<InputButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); - this.Input_CursorMoved = ManageEventOf<InputCursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); - this.Input_MouseWheelScrolled = ManageEventOf<InputMouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); - - this.World_BuildingListChanged = ManageEventOf<WorldBuildingListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LocationListChanged)); - this.World_DebrisListChanged = ManageEventOf<WorldDebrisListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.DebrisListChanged)); - this.World_LargeTerrainFeatureListChanged = ManageEventOf<WorldLargeTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LargeTerrainFeatureListChanged)); - this.World_LocationListChanged = ManageEventOf<WorldLocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged)); - this.World_NpcListChanged = ManageEventOf<WorldNpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged)); - this.World_ObjectListChanged = ManageEventOf<WorldObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); - this.World_TerrainFeatureListChanged = ManageEventOf<WorldTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); + this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); + this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering)); + this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered)); + this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld)); + this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld)); + this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu)); + this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu)); + this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud)); + this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud)); + this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized)); + + this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched)); + this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking)); + this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked)); + this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating)); + this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated)); + this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving)); + this.Saved = ManageEventOf<SavedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saved)); + this.SaveLoaded = ManageEventOf<SaveLoadedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveLoaded)); + this.DayStarted = ManageEventOf<DayStartedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.DayStarted)); + this.DayEnding = ManageEventOf<DayEndingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.DayEnding)); + this.TimeChanged = ManageEventOf<TimeChangedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.TimeChanged)); + this.ReturnedToTitle = ManageEventOf<ReturnedToTitleEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.ReturnedToTitle)); + + this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed)); + this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased)); + this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved)); + this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled)); + + this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived)); + this.ModMessageReceived = ManageEventOf<ModMessageReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.ModMessageReceived)); + this.PeerDisconnected = ManageEventOf<PeerDisconnectedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerDisconnected)); + + this.InventoryChanged = ManageEventOf<InventoryChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.InventoryChanged)); + this.LevelChanged = ManageEventOf<LevelChangedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.LevelChanged)); + this.Warped = ManageEventOf<WarpedEventArgs>(nameof(IModEvents.Player), nameof(IPlayerEvents.Warped)); + + this.BuildingListChanged = ManageEventOf<BuildingListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LocationListChanged)); + this.DebrisListChanged = ManageEventOf<DebrisListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.DebrisListChanged)); + this.LargeTerrainFeatureListChanged = ManageEventOf<LargeTerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.LargeTerrainFeatureListChanged)); + this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged)); + this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged)); + this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); + this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); + + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicked)); // init events (old) - this.Content_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); - - this.Legacy_Control_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); - this.Legacy_Control_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); - this.Legacy_Control_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); - this.Legacy_Control_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); - this.Legacy_Control_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); - this.Legacy_Control_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); - this.Legacy_Control_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); - this.Legacy_Control_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); - - this.Game_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); - this.Game_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); - this.Game_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); - this.Game_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); - this.Game_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); - this.Game_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); - this.Game_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); - this.Game_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); - - this.Graphics_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); - this.Graphics_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); - this.Graphics_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); - this.Graphics_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); - this.Graphics_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); - this.Graphics_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); - this.Graphics_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); - - this.Legacy_Input_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); - this.Legacy_Input_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); - - this.Legacy_Location_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); - this.Legacy_Location_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); - this.Legacy_Location_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); - - this.Menu_Changed = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); - this.Menu_Closed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); - - this.Multiplayer_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); - this.Multiplayer_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); - this.Multiplayer_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); - this.Multiplayer_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); - - this.Mine_LevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); - - this.Player_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); - this.Player_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); - this.Player_Warped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); - - this.Save_BeforeCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); - this.Save_AfterCreate = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); - this.Save_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); - this.Save_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); - this.Save_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); - this.Save_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); - - this.Specialised_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); - - this.Time_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); - this.Time_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); + this.Legacy_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); + + this.Legacy_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); + this.Legacy_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); + this.Legacy_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); + this.Legacy_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); + this.Legacy_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); + this.Legacy_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); + this.Legacy_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); + this.Legacy_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); + + this.Legacy_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); + this.Legacy_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); + this.Legacy_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); + this.Legacy_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); + this.Legacy_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); + this.Legacy_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); + this.Legacy_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); + this.Legacy_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); + + this.Legacy_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); + this.Legacy_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); + this.Legacy_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); + this.Legacy_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); + this.Legacy_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); + this.Legacy_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); + this.Legacy_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); + + this.Legacy_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); + this.Legacy_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); + + this.Legacy_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); + this.Legacy_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); + this.Legacy_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); + + this.Legacy_MenuChanged = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); + this.Legacy_MenuClosed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); + + this.Legacy_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); + this.Legacy_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); + this.Legacy_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); + this.Legacy_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); + + this.Legacy_MineLevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); + + this.Legacy_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); + this.Legacy_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); + this.Legacy_PlayerWarped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); + + this.Legacy_BeforeCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); + this.Legacy_AfterCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); + this.Legacy_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); + this.Legacy_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); + this.Legacy_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); + this.Legacy_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); + + this.Legacy_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); + + this.Legacy_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); + this.Legacy_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index c1ebf6c7..65f6e38e 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -67,6 +67,30 @@ namespace StardewModdingAPI.Framework.Events } } } + + /// <summary>Raise the event and notify all handlers.</summary> + /// <param name="args">The event arguments to pass.</param> + /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> + public void RaiseForMods(TEventArgs args, Func<IModMetadata, bool> match) + { + if (this.Event == null) + return; + + foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList) + { + if (match(this.GetSourceMod(handler))) + { + try + { + handler.Invoke(null, args); + } + catch (Exception ex) + { + this.LogError(handler, ex); + } + } + } + } } /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs index f3a278dc..defd903a 100644 --- a/src/SMAPI/Framework/Events/ManagedEventBase.cs +++ b/src/SMAPI/Framework/Events/ManagedEventBase.cs @@ -69,12 +69,22 @@ namespace StardewModdingAPI.Framework.Events this.SourceMods.Remove(handler); } + /// <summary>Get the mod which registered the given event handler, if available.</summary> + /// <param name="handler">The event handler.</param> + protected IModMetadata GetSourceMod(TEventHandler handler) + { + return this.SourceMods.TryGetValue(handler, out IModMetadata mod) + ? mod + : null; + } + /// <summary>Log an exception from an event handler.</summary> /// <param name="handler">The event handler instance.</param> /// <param name="ex">The exception that was raised.</param> protected void LogError(TEventHandler handler, Exception ex) { - if (this.SourceMods.TryGetValue(handler, out IModMetadata mod)) + IModMetadata mod = this.GetSourceMod(handler); + if (mod != null) mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); else this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs new file mode 100644 index 00000000..e383eec6 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs @@ -0,0 +1,93 @@ +using System; +using StardewModdingAPI.Events; +using StardewValley; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events related to UI and drawing to the screen.</summary> + internal class ModDisplayEvents : ModEventsBase, IDisplayEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + public event EventHandler<MenuChangedEventArgs> MenuChanged + { + add => this.EventManager.MenuChanged.Add(value); + remove => this.EventManager.MenuChanged.Remove(value); + } + + /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public event EventHandler<RenderingEventArgs> Rendering + { + add => this.EventManager.Rendering.Add(value); + remove => this.EventManager.Rendering.Remove(value); + } + + /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + public event EventHandler<RenderedEventArgs> Rendered + { + add => this.EventManager.Rendered.Add(value); + remove => this.EventManager.Rendered.Remove(value); + } + + /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + public event EventHandler<RenderingWorldEventArgs> RenderingWorld + { + add => this.EventManager.RenderingWorld.Add(value); + remove => this.EventManager.RenderingWorld.Remove(value); + } + + /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> + public event EventHandler<RenderedWorldEventArgs> RenderedWorld + { + add => this.EventManager.RenderedWorld.Add(value); + remove => this.EventManager.RenderedWorld.Remove(value); + } + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> + public event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu + { + add => this.EventManager.RenderingActiveMenu.Add(value); + remove => this.EventManager.RenderingActiveMenu.Remove(value); + } + + /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> + public event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu + { + add => this.EventManager.RenderedActiveMenu.Add(value); + remove => this.EventManager.RenderedActiveMenu.Remove(value); + } + + /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> + public event EventHandler<RenderingHudEventArgs> RenderingHud + { + add => this.EventManager.RenderingHud.Add(value); + remove => this.EventManager.RenderingHud.Remove(value); + } + + /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> + public event EventHandler<RenderedHudEventArgs> RenderedHud + { + add => this.EventManager.RenderedHud.Add(value); + remove => this.EventManager.RenderedHud.Remove(value); + } + + /// <summary>Raised after the game window is resized.</summary> + public event EventHandler<WindowResizedEventArgs> WindowResized + { + add => this.EventManager.WindowResized.Add(value); + remove => this.EventManager.WindowResized.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModDisplayEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs index 9e474457..8ad3936c 100644 --- a/src/SMAPI/Framework/Events/ModEvents.cs +++ b/src/SMAPI/Framework/Events/ModEvents.cs @@ -8,15 +8,27 @@ namespace StardewModdingAPI.Framework.Events /********* ** Accessors *********/ + /// <summary>Events related to UI and drawing to the screen.</summary> + public IDisplayEvents Display { get; } + /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IModEvents.Input"/> if possible.</summary> public IGameLoopEvents GameLoop { get; } /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> public IInputEvents Input { get; } + /// <summary>Events raised for multiplayer messages and connections.</summary> + public IMultiplayerEvents Multiplayer { get; } + + /// <summary>Events raised when the player data changes.</summary> + public IPlayerEvents Player { get; } + /// <summary>Events raised when something changes in the world.</summary> public IWorldEvents World { get; } + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + public ISpecialisedEvents Specialised { get; } + /********* ** Public methods @@ -26,9 +38,13 @@ namespace StardewModdingAPI.Framework.Events /// <param name="eventManager">The underlying event manager.</param> public ModEvents(IModMetadata mod, EventManager eventManager) { + this.Display = new ModDisplayEvents(mod, eventManager); this.GameLoop = new ModGameLoopEvents(mod, eventManager); this.Input = new ModInputEvents(mod, eventManager); + this.Multiplayer = new ModMultiplayerEvents(mod, eventManager); + this.Player = new ModPlayerEvents(mod, eventManager); this.World = new ModWorldEvents(mod, eventManager); + this.Specialised = new ModSpecialisedEvents(mod, eventManager); } } } diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index 379a4e96..a5beac99 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -10,24 +10,88 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after the game is launched, right before the first update tick.</summary> - public event EventHandler<GameLoopLaunchedEventArgs> Launched + public event EventHandler<GameLaunchedEventArgs> GameLaunched { - add => this.EventManager.GameLoop_Launched.Add(value); - remove => this.EventManager.GameLoop_Launched.Remove(value); + add => this.EventManager.GameLaunched.Add(value); + remove => this.EventManager.GameLaunched.Remove(value); } /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> - public event EventHandler<GameLoopUpdatingEventArgs> Updating + public event EventHandler<UpdateTickingEventArgs> UpdateTicking { - add => this.EventManager.GameLoop_Updating.Add(value); - remove => this.EventManager.GameLoop_Updating.Remove(value); + add => this.EventManager.UpdateTicking.Add(value); + remove => this.EventManager.UpdateTicking.Remove(value); } /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> - public event EventHandler<GameLoopUpdatedEventArgs> Updated + public event EventHandler<UpdateTickedEventArgs> UpdateTicked { - add => this.EventManager.GameLoop_Updated.Add(value); - remove => this.EventManager.GameLoop_Updated.Remove(value); + add => this.EventManager.UpdateTicked.Add(value); + remove => this.EventManager.UpdateTicked.Remove(value); + } + + /// <summary>Raised before the game creates a new save file.</summary> + public event EventHandler<SaveCreatingEventArgs> SaveCreating + { + add => this.EventManager.SaveCreating.Add(value); + remove => this.EventManager.SaveCreating.Remove(value); + } + + /// <summary>Raised after the game finishes creating the save file.</summary> + public event EventHandler<SaveCreatedEventArgs> SaveCreated + { + add => this.EventManager.SaveCreated.Add(value); + remove => this.EventManager.SaveCreated.Remove(value); + } + + /// <summary>Raised before the game begins writes data to the save file.</summary> + public event EventHandler<SavingEventArgs> Saving + { + add => this.EventManager.Saving.Add(value); + remove => this.EventManager.Saving.Remove(value); + } + + /// <summary>Raised after the game finishes writing data to the save file.</summary> + public event EventHandler<SavedEventArgs> Saved + { + add => this.EventManager.Saved.Add(value); + remove => this.EventManager.Saved.Remove(value); + } + + /// <summary>Raised after the player loads a save slot.</summary> + public event EventHandler<SaveLoadedEventArgs> SaveLoaded + { + add => this.EventManager.SaveLoaded.Add(value); + remove => this.EventManager.SaveLoaded.Remove(value); + } + + /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> + public event EventHandler<DayStartedEventArgs> DayStarted + { + add => this.EventManager.DayStarted.Add(value); + remove => this.EventManager.DayStarted.Remove(value); + } + + /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="IGameLoopEvents.Saving"/>.</summary> + public event EventHandler<DayEndingEventArgs> DayEnding + { + add => this.EventManager.DayEnding.Add(value); + remove => this.EventManager.DayEnding.Remove(value); + } + + /// <summary>Raised after the in-game clock time changes.</summary> + public event EventHandler<TimeChangedEventArgs> TimeChanged + { + + add => this.EventManager.TimeChanged.Add(value); + remove => this.EventManager.TimeChanged.Remove(value); + } + + /// <summary>Raised after the game returns to the title screen.</summary> + public event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle + { + add => this.EventManager.ReturnedToTitle.Add(value); + remove => this.EventManager.ReturnedToTitle.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs index feca34f3..6a4298b4 100644 --- a/src/SMAPI/Framework/Events/ModInputEvents.cs +++ b/src/SMAPI/Framework/Events/ModInputEvents.cs @@ -10,31 +10,31 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public event EventHandler<InputButtonPressedEventArgs> ButtonPressed + public event EventHandler<ButtonPressedEventArgs> ButtonPressed { - add => this.EventManager.Input_ButtonPressed.Add(value); - remove => this.EventManager.Input_ButtonPressed.Remove(value); + add => this.EventManager.ButtonPressed.Add(value); + remove => this.EventManager.ButtonPressed.Remove(value); } /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> - public event EventHandler<InputButtonReleasedEventArgs> ButtonReleased + public event EventHandler<ButtonReleasedEventArgs> ButtonReleased { - add => this.EventManager.Input_ButtonReleased.Add(value); - remove => this.EventManager.Input_ButtonReleased.Remove(value); + add => this.EventManager.ButtonReleased.Add(value); + remove => this.EventManager.ButtonReleased.Remove(value); } /// <summary>Raised after the player moves the in-game cursor.</summary> - public event EventHandler<InputCursorMovedEventArgs> CursorMoved + public event EventHandler<CursorMovedEventArgs> CursorMoved { - add => this.EventManager.Input_CursorMoved.Add(value); - remove => this.EventManager.Input_CursorMoved.Remove(value); + add => this.EventManager.CursorMoved.Add(value); + remove => this.EventManager.CursorMoved.Remove(value); } /// <summary>Raised after the player scrolls the mouse wheel.</summary> - public event EventHandler<InputMouseWheelScrolledEventArgs> MouseWheelScrolled + public event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled { - add => this.EventManager.Input_MouseWheelScrolled.Add(value); - remove => this.EventManager.Input_MouseWheelScrolled.Remove(value); + add => this.EventManager.MouseWheelScrolled.Add(value); + remove => this.EventManager.MouseWheelScrolled.Remove(value); } diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs new file mode 100644 index 00000000..152c4e0c --- /dev/null +++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs @@ -0,0 +1,43 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events raised for multiplayer messages and connections.</summary> + internal class ModMultiplayerEvents : ModEventsBase, IMultiplayerEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection, so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived + { + add => this.EventManager.PeerContextReceived.Add(value); + remove => this.EventManager.PeerContextReceived.Remove(value); + } + + /// <summary>Raised after a mod message is received over the network.</summary> + public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived + { + add => this.EventManager.ModMessageReceived.Add(value); + remove => this.EventManager.ModMessageReceived.Remove(value); + } + + /// <summary>Raised after the connection with a peer is severed.</summary> + public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected + { + add => this.EventManager.PeerDisconnected.Add(value); + remove => this.EventManager.PeerDisconnected.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModMultiplayerEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModPlayerEvents.cs b/src/SMAPI/Framework/Events/ModPlayerEvents.cs new file mode 100644 index 00000000..ca7cfd96 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModPlayerEvents.cs @@ -0,0 +1,43 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events raised when the player data changes.</summary> + internal class ModPlayerEvents : ModEventsBase, IPlayerEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<InventoryChangedEventArgs> InventoryChanged + { + add => this.EventManager.InventoryChanged.Add(value); + remove => this.EventManager.InventoryChanged.Remove(value); + } + + /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<LevelChangedEventArgs> LevelChanged + { + add => this.EventManager.LevelChanged.Add(value); + remove => this.EventManager.LevelChanged.Remove(value); + } + + /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the local player.</summary> + public event EventHandler<WarpedEventArgs> Warped + { + add => this.EventManager.Warped.Add(value); + remove => this.EventManager.Warped.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModPlayerEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs new file mode 100644 index 00000000..17c32bb8 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -0,0 +1,36 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> + internal class ModSpecialisedEvents : ModEventsBase, ISpecialisedEvents + { + /********* + ** Accessors + *********/ + /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + public event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking + { + add => this.EventManager.UnvalidatedUpdateTicking.Add(value); + remove => this.EventManager.UnvalidatedUpdateTicking.Remove(value); + } + + /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + public event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked + { + add => this.EventManager.UnvalidatedUpdateTicked.Add(value); + remove => this.EventManager.UnvalidatedUpdateTicked.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModSpecialisedEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs index dc9c0f4c..b85002a3 100644 --- a/src/SMAPI/Framework/Events/ModWorldEvents.cs +++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs @@ -10,52 +10,52 @@ namespace StardewModdingAPI.Framework.Events ** Accessors *********/ /// <summary>Raised after a game location is added or removed.</summary> - public event EventHandler<WorldLocationListChangedEventArgs> LocationListChanged + public event EventHandler<LocationListChangedEventArgs> LocationListChanged { - add => this.EventManager.World_LocationListChanged.Add(value, this.Mod); - remove => this.EventManager.World_LocationListChanged.Remove(value); + add => this.EventManager.LocationListChanged.Add(value, this.Mod); + remove => this.EventManager.LocationListChanged.Remove(value); } /// <summary>Raised after buildings are added or removed in a location.</summary> - public event EventHandler<WorldBuildingListChangedEventArgs> BuildingListChanged + public event EventHandler<BuildingListChangedEventArgs> BuildingListChanged { - add => this.EventManager.World_BuildingListChanged.Add(value, this.Mod); - remove => this.EventManager.World_BuildingListChanged.Remove(value); + add => this.EventManager.BuildingListChanged.Add(value, this.Mod); + remove => this.EventManager.BuildingListChanged.Remove(value); } /// <summary>Raised after debris are added or removed in a location.</summary> - public event EventHandler<WorldDebrisListChangedEventArgs> DebrisListChanged + public event EventHandler<DebrisListChangedEventArgs> DebrisListChanged { - add => this.EventManager.World_DebrisListChanged.Add(value, this.Mod); - remove => this.EventManager.World_DebrisListChanged.Remove(value); + add => this.EventManager.DebrisListChanged.Add(value, this.Mod); + remove => this.EventManager.DebrisListChanged.Remove(value); } /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> - public event EventHandler<WorldLargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged + public event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged { - add => this.EventManager.World_LargeTerrainFeatureListChanged.Add(value, this.Mod); - remove => this.EventManager.World_LargeTerrainFeatureListChanged.Remove(value); + add => this.EventManager.LargeTerrainFeatureListChanged.Add(value, this.Mod); + remove => this.EventManager.LargeTerrainFeatureListChanged.Remove(value); } /// <summary>Raised after NPCs are added or removed in a location.</summary> - public event EventHandler<WorldNpcListChangedEventArgs> NpcListChanged + public event EventHandler<NpcListChangedEventArgs> NpcListChanged { - add => this.EventManager.World_NpcListChanged.Add(value); - remove => this.EventManager.World_NpcListChanged.Remove(value); + add => this.EventManager.NpcListChanged.Add(value); + remove => this.EventManager.NpcListChanged.Remove(value); } /// <summary>Raised after objects are added or removed in a location.</summary> - public event EventHandler<WorldObjectListChangedEventArgs> ObjectListChanged + public event EventHandler<ObjectListChangedEventArgs> ObjectListChanged { - add => this.EventManager.World_ObjectListChanged.Add(value); - remove => this.EventManager.World_ObjectListChanged.Remove(value); + add => this.EventManager.ObjectListChanged.Add(value); + remove => this.EventManager.ObjectListChanged.Remove(value); } /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> - public event EventHandler<WorldTerrainFeatureListChangedEventArgs> TerrainFeatureListChanged + public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged { - add => this.EventManager.World_TerrainFeatureListChanged.Add(value); - remove => this.EventManager.World_TerrainFeatureListChanged.Remove(value); + add => this.EventManager.TerrainFeatureListChanged.Add(value); + remove => this.EventManager.TerrainFeatureListChanged.Remove(value); } diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 2145105b..7ada7dea 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -1,11 +1,13 @@ +using System.Collections.Generic; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Framework { /// <summary>Metadata for a mod.</summary> - internal interface IModMetadata + internal interface IModMetadata : IModInfo { /********* ** Accessors @@ -16,8 +18,8 @@ namespace StardewModdingAPI.Framework /// <summary>The mod's full directory path.</summary> string DirectoryPath { get; } - /// <summary>The mod manifest.</summary> - IManifest Manifest { get; } + /// <summary>The <see cref="DirectoryPath"/> relative to the game's Mods folder.</summary> + string RelativeDirectoryPath { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> ModDataRecordVersionedFields DataRecord { get; } @@ -31,10 +33,13 @@ namespace StardewModdingAPI.Framework /// <summary>The reason the metadata is invalid, if any.</summary> string Error { get; } - /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + bool IsIgnored { get; } + + /// <summary>The mod instance (if loaded and <see cref="IModInfo.IsContentPack"/> is false).</summary> IMod Mod { get; } - /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> + /// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary> IContentPack ContentPack { get; } /// <summary>Writes messages to the console and log file as this mod.</summary> @@ -43,9 +48,6 @@ namespace StardewModdingAPI.Framework /// <summary>The mod-provided API (if any).</summary> object Api { get; } - /// <summary>Whether the mod is a content pack.</summary> - bool IsContentPack { get; } - /// <summary>The update-check metadata for this mod (if any).</summary> ModEntryModel UpdateCheckData { get; } @@ -86,7 +88,15 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the mod has an ID (regardless of whether the ID is valid or the mod itself was loaded).</summary> bool HasID(); - /// <summary>Whether the mod has at least one update key set.</summary> - bool HasUpdateKeys(); + /// <summary>Whether the mod has the given ID.</summary> + /// <param name="id">The mod ID to check.</param> + bool HasID(string id); + + /// <summary>Get the defined update keys.</summary> + /// <param name="validOnly">Only return valid update keys.</param> + IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true); + + /// <summary>Whether the mod has at least one valid update key set.</summary> + bool HasValidUpdateKeys(); } } diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index ff3925fb..f52bfe2b 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Reflection; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Reflection; using StardewValley; @@ -40,6 +41,17 @@ namespace StardewModdingAPI.Framework } /**** + ** ManagedEvent + ****/ + /// <summary>Raise the event using the default event args and notify all handlers.</summary> + /// <typeparam name="TEventArgs">The event args type to construct.</typeparam> + /// <param name="event">The event to raise.</param> + public static void RaiseEmpty<TEventArgs>(this ManagedEvent<TEventArgs> @event) where TEventArgs : new() + { + @event.Raise(Singleton<TEventArgs>.Instance); + } + + /**** ** Exceptions ****/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index bdedb07c..5a3304f3 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -8,8 +8,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Accessors *********/ - /// <summary>The friendly mod name for this instance.</summary> - private readonly string ModName; + /// <summary>The mod using this instance.</summary> + private readonly IModMetadata Mod; /// <summary>Manages console commands.</summary> private readonly CommandManager CommandManager; @@ -19,13 +19,12 @@ namespace StardewModdingAPI.Framework.ModHelpers ** 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="mod">The mod using this instance.</param> /// <param name="commandManager">Manages console commands.</param> - public CommandHelper(string modID, string modName, CommandManager commandManager) - : base(modID) + public CommandHelper(IModMetadata mod, CommandManager commandManager) + : base(mod?.Manifest?.UniqueID ?? "SMAPI") { - this.ModName = modName; + this.Mod = mod; this.CommandManager = commandManager; } @@ -38,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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); + this.CommandManager.Add(this.Mod, name, documentation, callback); return this; } diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs new file mode 100644 index 00000000..e5100aed --- /dev/null +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using Newtonsoft.Json; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <summary>Provides an API for reading and storing local mod data.</summary> + internal class DataHelper : BaseHelper, IDataHelper + { + /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + /// <summary>The absolute path to the mod folder.</summary> + private readonly string ModFolderPath; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="modFolderPath">The absolute path to the mod folder.</param> + /// <param name="jsonHelper">The absolute path to the mod folder.</param> + public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper) + : base(modID) + { + this.ModFolderPath = modFolderPath; + this.JsonHelper = jsonHelper; + } + + /**** + ** JSON file + ****/ + /// <summary>Read data from a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public TModel ReadJsonFile<TModel>(string path) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + ? data + : null; + } + + /// <summary>Save data to a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); + + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + this.JsonHelper.WriteJsonFile(path, data); + } + + /**** + ** Save file + ****/ + /// <summary>Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + public TModel ReadSaveData<TModel>(string key) where TModel : class + { + if (!Context.IsSaveLoaded) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded."); + if (!Context.IsMainPlayer) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); + + return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value) + ? this.JsonHelper.Deserialise<TModel>(value) + : null; + } + + /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + public void WriteSaveData<TModel>(string key, TModel data) where TModel : class + { + if (!Context.IsSaveLoaded) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded."); + if (!Context.IsMainPlayer) + throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); + + string internalKey = this.GetSaveFileKey(key); + if (data != null) + Game1.CustomData[internalKey] = this.JsonHelper.Serialise(data, Formatting.None); + else + Game1.CustomData.Remove(internalKey); + } + + /**** + ** Global app data + ****/ + /// <summary>Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + public TModel ReadGlobalData<TModel>(string key) where TModel : class + { + string path = this.GetGlobalDataPath(key); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + ? data + : null; + } + + /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + public void WriteGlobalData<TModel>(string key, TModel data) where TModel : class + { + string path = this.GetGlobalDataPath(key); + if (data != null) + this.JsonHelper.WriteJsonFile(path, data); + else + File.Delete(path); + } + + + /********* + ** Public methods + *********/ + /// <summary>Get the unique key for a save file data entry.</summary> + /// <param name="key">The unique key identifying the data.</param> + private string GetSaveFileKey(string key) + { + this.AssertSlug(key, nameof(key)); + return $"smapi/mod-data/{this.ModID}/{key}".ToLower(); + } + + /// <summary>Get the absolute path for a global data file.</summary> + /// <param name="key">The unique key identifying the data.</param> + private string GetGlobalDataPath(string key) + { + this.AssertSlug(key, nameof(key)); + return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower()); + } + + /// <summary>Assert that a key contains only characters that are safe in all contexts.</summary> + /// <param name="key">The key to check.</param> + /// <param name="paramName">The argument name for any assertion error.</param> + private void AssertSlug(string key, string paramName) + { + if (!PathUtilities.IsSlug(key)) + throw new ArgumentException("The data key is invalid (keys must only contain letters, numbers, underscores, periods, or hyphens).", paramName); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 0ba258b4..5e190e55 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Linq; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Toolkit.Serialisation; @@ -16,11 +15,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Properties *********/ - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; - /// <summary>The content packs loaded for this mod.</summary> - private readonly IContentPack[] ContentPacks; + private readonly Lazy<IContentPack[]> ContentPacks; /// <summary>Create a transitional content pack.</summary> private readonly Func<string, IManifest, IContentPack> CreateContentPack; @@ -35,12 +31,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The full path to the mod's folder.</summary> public string DirectoryPath { get; } + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary> public IModEvents Events { get; } /// <summary>An API for loading content assets.</summary> public IContentHelper Content { get; } + /// <summary>An API for reading and writing persistent mod data.</summary> + public IDataHelper Data { get; } + /// <summary>An API for checking and changing input state.</summary> public IInputHelper Input { get; } @@ -71,6 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="events">Manages access to events raised by SMAPI.</param> /// <param name="contentHelper">An API for loading content assets.</param> /// <param name="commandHelper">An API for managing console commands.</param> + /// <param name="dataHelper">An API for reading and writing persistent mod data.</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="multiplayer">Provides multiplayer utilities.</param> @@ -80,7 +83,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="deprecationManager">Manages deprecation warnings.</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, SInputState inputState, IModEvents events, IContentHelper contentHelper, ICommandHelper commandHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, IEnumerable<IContentPack> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager) + public ModHelper(string modID, string modDirectory, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper, Func<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack, DeprecationManager deprecationManager) : base(modID) { // validate directory @@ -93,13 +96,14 @@ namespace StardewModdingAPI.Framework.ModHelpers this.DirectoryPath = modDirectory; this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); + this.Data = dataHelper ?? throw new ArgumentNullException(nameof(dataHelper)); this.Input = new InputHelper(modID, inputState); 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.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); - this.ContentPacks = contentPacks.ToArray(); + this.ContentPacks = new Lazy<IContentPack[]>(contentPacks); this.CreateContentPack = createContentPack; this.DeprecationManager = deprecationManager; this.Events = events; @@ -113,7 +117,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public TConfig ReadConfig<TConfig>() where TConfig : class, new() { - TConfig config = this.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); + TConfig config = this.Data.ReadJsonFile<TConfig>("config.json") ?? new TConfig(); this.WriteConfig(config); // create file or fill in missing fields return config; } @@ -124,7 +128,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public void WriteConfig<TConfig>(TConfig config) where TConfig : class, new() { - this.WriteJsonFile("config.json", config); + this.Data.WriteJsonFile("config.json", config); } /**** @@ -134,6 +138,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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> + [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] public TModel ReadJsonFile<TModel>(string path) where TModel : class { @@ -147,6 +152,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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> + [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] public void WriteJsonFile<TModel>(string path, TModel model) where TModel : class { @@ -197,7 +203,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Get all content packs loaded for this mod.</summary> public IEnumerable<IContentPack> GetContentPacks() { - return this.ContentPacks; + return this.ContentPacks.Value; } /**** diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 008a80f5..5cc2a20f 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -40,17 +40,17 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <summary>Get metadata for all loaded mods.</summary> - public IEnumerable<IManifest> GetAll() + public IEnumerable<IModInfo> GetAll() { - return this.Registry.GetAll().Select(p => p.Manifest); + 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) + public IModInfo Get(string uniqueID) { - return this.Registry.Get(uniqueID)?.Manifest; + return this.Registry.Get(uniqueID); } /// <summary>Get whether a mod has been loaded.</summary> diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index c449a51b..eedad0bc 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using StardewModdingAPI.Framework.Networking; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -25,16 +27,50 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer; } + /// <summary>Get a new multiplayer ID.</summary> + public long GetNewID() + { + return this.Multiplayer.getNewID(); + } + /// <summary>Get the locations which are being actively synced from the host.</summary> public IEnumerable<GameLocation> GetActiveLocations() { return this.Multiplayer.activeLocations(); } - /// <summary>Get a new multiplayer ID.</summary> - public long GetNewID() + /// <summary>Get a connected player.</summary> + /// <param name="id">The player's unique ID.</param> + /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> + public IMultiplayerPeer GetConnectedPlayer(long id) { - return this.Multiplayer.getNewID(); + return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer) + ? peer + : null; + } + + /// <summary>Get all connected players.</summary> + public IEnumerable<IMultiplayerPeer> GetConnectedPlayers() + { + return this.Multiplayer.Peers.Values; + } + + /// <summary>Send a message to mods installed by connected players.</summary> + /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> + public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null) + { + this.Multiplayer.BroadcastModMessage( + message: message, + messageType: messageType, + fromModID: this.ModID, + toModIDs: modIDs, + toPlayerIDs: playerIDs + ); } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 37b1a378..fdbfdd8d 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -45,6 +45,7 @@ namespace StardewModdingAPI.Framework.ModLoading this.AssemblyMap = this.TrackForDisposal(Constants.GetAssemblyMap(targetPlatform)); this.AssemblyDefinitionResolver = this.TrackForDisposal(new AssemblyDefinitionResolver()); this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.ExecutionPath); + this.AssemblyDefinitionResolver.AddSearchDirectory(Constants.InternalFilesPath); // generate type => assembly lookup for types which should be rewritten this.TypeAssemblies = new Dictionary<string, Assembly>(); @@ -349,6 +350,16 @@ namespace StardewModdingAPI.Framework.ModLoading mod.SetWarning(ModWarning.UsesDynamic); break; + case InstructionHandleResult.DetectedFilesystemAccess: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.AccessesFilesystem); + break; + + case InstructionHandleResult.DetectedShellAccess: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected shell or process access ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.AccessesShell); + break; + case InstructionHandleResult.None: break; diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index cfa23d08..f3555c2d 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -24,6 +24,12 @@ namespace StardewModdingAPI.Framework.ModLoading DetectedDynamic, /// <summary>The instruction is compatible, but references <see cref="SpecialisedEvents.UnvalidatedUpdateTick"/> which may impact stability.</summary> - DetectedUnvalidatedUpdateTick + DetectedUnvalidatedUpdateTick, + + /// <summary>The instruction accesses the filesystem directly.</summary> + DetectedFilesystemAccess, + + /// <summary>The instruction accesses the OS shell or processes directly.</summary> + DetectedShellAccess } } diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 585debb4..0cb62a75 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,7 +1,9 @@ using System; +using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Framework.ModLoading { @@ -17,6 +19,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The mod's full directory path.</summary> public string DirectoryPath { get; } + /// <summary>The <see cref="IModMetadata.DirectoryPath"/> relative to the game's Mods folder.</summary> + public string RelativeDirectoryPath { get; } + /// <summary>The mod manifest.</summary> public IManifest Manifest { get; } @@ -32,6 +37,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The reason the metadata is invalid, if any.</summary> public string Error { get; private set; } + /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> + public bool IsIgnored { get; } + /// <summary>The mod instance (if loaded and <see cref="IsContentPack"/> is false).</summary> public IMod Mod { get; private set; } @@ -57,14 +65,18 @@ namespace StardewModdingAPI.Framework.ModLoading /// <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="relativeDirectoryPath">The <paramref name="directoryPath"/> relative to the game's Mods folder.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param> - public ModMetadata(string displayName, string directoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord) + /// <param name="isIgnored">Whether the mod folder should be ignored. This should be <c>true</c> if it was found within a folder whose name starts with a dot.</param> + public ModMetadata(string displayName, string directoryPath, string relativeDirectoryPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; + this.RelativeDirectoryPath = relativeDirectoryPath; this.Manifest = manifest; this.DataRecord = dataRecord; + this.IsIgnored = isIgnored; } /// <summary>Set the mod status.</summary> @@ -141,13 +153,31 @@ namespace StardewModdingAPI.Framework.ModLoading && !string.IsNullOrWhiteSpace(this.Manifest.UniqueID); } - /// <summary>Whether the mod has at least one update key set.</summary> - public bool HasUpdateKeys() + /// <summary>Whether the mod has the given ID.</summary> + /// <param name="id">The mod ID to check.</param> + public bool HasID(string id) { return - this.HasManifest() - && this.Manifest.UpdateKeys != null - && this.Manifest.UpdateKeys.Any(key => !string.IsNullOrWhiteSpace(key)); + this.HasID() + && string.Equals(this.Manifest.UniqueID.Trim(), id?.Trim(), StringComparison.InvariantCultureIgnoreCase); + } + + /// <summary>Get the defined update keys.</summary> + /// <param name="validOnly">Only return valid update keys.</param> + public IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = false) + { + foreach (string rawKey in this.Manifest?.UpdateKeys ?? new string[0]) + { + UpdateKey updateKey = UpdateKey.Parse(rawKey); + if (updateKey.LooksValid || !validOnly) + yield return updateKey; + } + } + + /// <summary>Whether the mod has at least one valid update key set.</summary> + public bool HasValidUpdateKeys() + { + return this.GetUpdateKeys(validOnly: true).Any(); } } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 9ac95fd4..ace84054 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; @@ -31,13 +30,6 @@ namespace StardewModdingAPI.Framework.ModLoading // parse internal data record (if any) ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); - // get display name - string displayName = manifest?.Name; - if (string.IsNullOrWhiteSpace(displayName)) - displayName = dataRecord?.DisplayName; - if (string.IsNullOrWhiteSpace(displayName)) - displayName = PathUtilities.GetRelativePath(rootPath, folder.ActualDirectory?.FullName ?? folder.SearchDirectory.FullName); - // apply defaults if (manifest != null && dataRecord != null) { @@ -46,10 +38,13 @@ namespace StardewModdingAPI.Framework.ModLoading } // build metadata - ModMetadataStatus status = folder.ManifestParseError == null + ModMetadataStatus status = folder.ManifestParseError == null || !folder.ShouldBeLoaded ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - yield return new ModMetadata(displayName, folder.ActualDirectory?.FullName, manifest, dataRecord).SetStatus(status, folder.ManifestParseError); + string relativePath = PathUtilities.GetRelativePath(rootPath, folder.Directory.FullName); + + yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded) + .SetStatus(status, !folder.ShouldBeLoaded ? "disabled by dot convention" : folder.ManifestParseError); } } @@ -92,7 +87,7 @@ namespace StardewModdingAPI.Framework.ModLoading updateUrls.Add(mod.DataRecord.AlternativeUrl); // default update URL - updateUrls.Add("https://smapi.io/compat"); + updateUrls.Add("https://mods.smapi.io"); // build error string error = $"{reasonPhrase}. Please check for a "; @@ -181,7 +176,7 @@ namespace StardewModdingAPI.Framework.ModLoading } // validate ID format - if (Regex.IsMatch(mod.Manifest.UniqueID, "[^a-z0-9_.-]", RegexOptions.IgnoreCase)) + if (!PathUtilities.IsSlug(mod.Manifest.UniqueID)) mod.SetStatus(ModMetadataStatus.Failed, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); } @@ -196,7 +191,7 @@ namespace StardewModdingAPI.Framework.ModLoading { 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))})."); + mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed ({string.Join(", ", group.Select(p => p.RelativeDirectoryPath).OrderBy(p => p))})."); } } } @@ -386,7 +381,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="loadedMods">The loaded mods.</param> private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) { - IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => string.Equals(m.Manifest?.UniqueID, id, StringComparison.InvariantCultureIgnoreCase)); + IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); // yield dependencies if (manifest.Dependencies != null) diff --git a/src/SMAPI/Framework/ModLoading/ModWarning.cs b/src/SMAPI/Framework/ModLoading/ModWarning.cs index 0e4b2570..c62199b2 100644 --- a/src/SMAPI/Framework/ModLoading/ModWarning.cs +++ b/src/SMAPI/Framework/ModLoading/ModWarning.cs @@ -26,6 +26,12 @@ namespace StardewModdingAPI.Framework.ModLoading UsesUnvalidatedUpdateTick = 16, /// <summary>The mod has no update keys set.</summary> - NoUpdateKeys = 32 + NoUpdateKeys = 32, + + /// <summary>Uses .NET APIs for filesystem access.</summary> + AccessesFilesystem = 64, + + /// <summary>Uses .NET APIs for shell or process access.</summary> + AccessesShell = 128 } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs b/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs deleted file mode 100644 index 322a7df1..00000000 --- a/src/SMAPI/Framework/ModLoading/Rewriters/VirtualEntryCallRemover.cs +++ /dev/null @@ -1,90 +0,0 @@ -using System; -using Mono.Cecil; -using Mono.Cecil.Cil; - -namespace StardewModdingAPI.Framework.ModLoading.Rewriters -{ - /// <summary>Rewrites virtual calls to the <see cref="Mod.Entry"/> method.</summary> - internal class VirtualEntryCallRemover : IInstructionHandler - { - /********* - ** Properties - *********/ - /// <summary>The type containing the method.</summary> - private readonly Type ToType; - - /// <summary>The name of the method.</summary> - private readonly string MethodName; - - - /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase indicating what the instruction finder matches.</summary> - public string NounPhrase { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - public VirtualEntryCallRemover() - { - this.ToType = typeof(Mod); - this.MethodName = nameof(Mod.Entry); - this.NounPhrase = $"{this.ToType.Name}::{this.MethodName}"; - } - - /// <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)) - return InstructionHandleResult.None; - - // get instructions comprising method call - int index = cil.Body.Instructions.IndexOf(instruction); - Instruction loadArg0 = cil.Body.Instructions[index - 2]; - Instruction loadArg1 = cil.Body.Instructions[index - 1]; - if (loadArg0.OpCode != OpCodes.Ldarg_0) - throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg0.OpCode.Name} instead of {OpCodes.Ldarg_0.Name}"); - if (loadArg1.OpCode != OpCodes.Ldarg_1) - throw new InvalidOperationException($"Unexpected instruction sequence while removing virtual {this.ToType.Name}.{this.MethodName} call: found {loadArg1.OpCode.Name} instead of {OpCodes.Ldarg_1.Name}"); - - // remove method call - cil.Remove(loadArg0); - cil.Remove(loadArg1); - cil.Remove(instruction); - return InstructionHandleResult.Rewritten; - } - - - /********* - ** 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.ToType.FullName - && methodRef.Name == this.MethodName; - } - } -} diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index e7d4f89a..8ce3172c 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -18,6 +18,9 @@ namespace StardewModdingAPI.Framework /// <summary>An assembly full name => mod lookup.</summary> private readonly IDictionary<string, IModMetadata> ModNamesByAssembly = new Dictionary<string, IModMetadata>(); + /// <summary>Whether all mod assemblies have been loaded.</summary> + public bool AreAllModsLoaded { get; set; } + /// <summary>Whether all mods have been initialised and their <see cref="IMod.Entry"/> method called.</summary> public bool AreAllModsInitialised { get; set; } @@ -59,7 +62,7 @@ namespace StardewModdingAPI.Framework uniqueID = uniqueID.Trim(); // find match - return this.GetAll().FirstOrDefault(p => p.Manifest.UniqueID.Trim().Equals(uniqueID, StringComparison.InvariantCultureIgnoreCase)); + return this.GetAll().FirstOrDefault(p => p.HasID(uniqueID)); } /// <summary>Get the mod metadata from one of its assemblies.</summary> diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 15671af4..e2b33160 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -14,6 +14,14 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> public bool CheckForUpdates { get; set; } + /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> + public bool ParanoidWarnings { get; set; } = +#if DEBUG + true; +#else + false; +#endif + /// <summary>Whether to show beta versions as valid updates.</summary> public bool UseBetaChannel { get; set; } = Constants.ApiVersion.IsPrerelease(); diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 2812a9cc..a4d92e4b 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -37,6 +37,9 @@ namespace StardewModdingAPI.Framework /// <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; + /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> + public bool IsVerbose { get; } + /// <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; } @@ -56,7 +59,8 @@ namespace StardewModdingAPI.Framework /// <param name="logFile">The log file to which to write messages.</param> /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> /// <param name="colorScheme">The console color scheme to use.</param> - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme) + /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> + public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) @@ -68,6 +72,7 @@ namespace StardewModdingAPI.Framework this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme); this.ConsoleInterceptor = consoleInterceptor; this.ExitTokenSource = exitTokenSource; + this.IsVerbose = isVerbose; } /// <summary>Log a message for the player or developer.</summary> @@ -78,6 +83,14 @@ namespace StardewModdingAPI.Framework this.LogImpl(this.Source, message, (ConsoleLogLevel)level); } + /// <summary>Log a message that only appears when <see cref="IMonitor.IsVerbose"/> is enabled.</summary> + /// <param name="message">The message to log.</param> + public void VerboseLog(string message) + { + if (this.IsVerbose) + this.Log(message, LogLevel.Trace); + } + /// <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) diff --git a/src/SMAPI/Framework/Networking/MessageType.cs b/src/SMAPI/Framework/Networking/MessageType.cs new file mode 100644 index 00000000..bd9acfa9 --- /dev/null +++ b/src/SMAPI/Framework/Networking/MessageType.cs @@ -0,0 +1,26 @@ +using StardewValley; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Network message types recognised by SMAPI and Stardew Valley.</summary> + internal enum MessageType : byte + { + /********* + ** SMAPI + *********/ + /// <summary>A data message intended for mods to consume.</summary> + ModMessage = 254, + + /// <summary>Metadata context about a player synced by SMAPI.</summary> + ModContext = 255, + + /********* + ** Vanilla + *********/ + /// <summary>Metadata about the host server sent to a farmhand.</summary> + ServerIntroduction = Multiplayer.serverIntroduction, + + /// <summary>Metadata about a player sent to a farmhand or server.</summary> + PlayerIntroduction = Multiplayer.playerIntroduction + } +} diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs new file mode 100644 index 00000000..7ee39863 --- /dev/null +++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs @@ -0,0 +1,72 @@ +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>The metadata for a mod message.</summary> + internal class ModMessageModel + { + /********* + ** Accessors + *********/ + /**** + ** Origin + ****/ + /// <summary>The unique ID of the player who broadcast the message.</summary> + public long FromPlayerID { get; set; } + + /// <summary>The unique ID of the mod which broadcast the message.</summary> + public string FromModID { get; set; } + + /**** + ** Destination + ****/ + /// <summary>The players who should receive the message, or <c>null</c> for all players.</summary> + public long[] ToPlayerIDs { get; set; } + + /// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary> + public string[] ToModIDs { get; set; } + + /// <summary>A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</summary> + public string Type { get; set; } + + /// <summary>The custom mod data being broadcast.</summary> + public JToken Data { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public ModMessageModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="fromPlayerID">The unique ID of the player who broadcast the message.</param> + /// <param name="fromModID">The unique ID of the mod which broadcast the message.</param> + /// <param name="toPlayerIDs">The players who should receive the message, or <c>null</c> for all players.</param> + /// <param name="toModIDs">The mods which should receive the message, or <c>null</c> for all mods.</param> + /// <param name="type">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="data">The custom mod data being broadcast.</param> + public ModMessageModel(long fromPlayerID, string fromModID, long[] toPlayerIDs, string[] toModIDs, string type, JToken data) + { + this.FromPlayerID = fromPlayerID; + this.FromModID = fromModID; + this.ToPlayerIDs = toPlayerIDs; + this.ToModIDs = toModIDs; + this.Type = type; + this.Data = data; + } + + /// <summary>Construct an instance.</summary> + /// <param name="message">The message to clone.</param> + public ModMessageModel(ModMessageModel message) + { + this.FromPlayerID = message.FromPlayerID; + this.FromModID = message.FromModID; + this.ToPlayerIDs = message.ToPlayerIDs?.ToArray(); + this.ToModIDs = message.ToModIDs?.ToArray(); + this.Type = message.Type; + this.Data = message.Data; + } + } +} diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs new file mode 100644 index 00000000..44a71978 --- /dev/null +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about a connected player.</summary> + internal class MultiplayerPeer : IMultiplayerPeer + { + /********* + ** Properties + *********/ + /// <summary>A method which sends a message to the peer.</summary> + private readonly Action<OutgoingMessage> SendMessageImpl; + + + /********* + ** Accessors + *********/ + /// <summary>The player's unique ID.</summary> + public long PlayerID { get; } + + /// <summary>Whether this is a connection to the host player.</summary> + public bool IsHost { get; } + + /// <summary>Whether the player has SMAPI installed.</summary> + public bool HasSmapi => this.ApiVersion != null; + + /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> + public GamePlatform? Platform { get; } + + /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> + public ISemanticVersion GameVersion { get; } + + /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> + public ISemanticVersion ApiVersion { get; } + + /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> + public IEnumerable<IMultiplayerPeerMod> Mods { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="playerID">The player's unique ID.</param> + /// <param name="model">The metadata to copy.</param> + /// <param name="sendMessage">A method which sends a message to the peer.</param> + /// <param name="isHost">Whether this is a connection to the host player.</param> + public MultiplayerPeer(long playerID, RemoteContextModel model, Action<OutgoingMessage> sendMessage, bool isHost) + { + this.PlayerID = playerID; + this.IsHost = isHost; + if (model != null) + { + this.Platform = model.Platform; + this.GameVersion = model.GameVersion; + this.ApiVersion = model.ApiVersion; + this.Mods = model.Mods.Select(mod => new MultiplayerPeerMod(mod)).ToArray(); + } + this.SendMessageImpl = sendMessage; + } + + /// <summary>Get metadata for a mod installed by the player.</summary> + /// <param name="id">The unique mod ID.</param> + /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> + public IMultiplayerPeerMod GetMod(string id) + { + if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any()) + return null; + + id = id.Trim(); + return this.Mods.FirstOrDefault(mod => mod.ID != null && mod.ID.Equals(id, StringComparison.InvariantCultureIgnoreCase)); + } + + /// <summary>Send a message to the given peer, bypassing the game's normal validation to allow messages before the connection is approved.</summary> + /// <param name="message">The message to send.</param> + public void SendMessage(OutgoingMessage message) + { + this.SendMessageImpl(message); + } + } +} diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs new file mode 100644 index 00000000..1b324bcd --- /dev/null +++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs @@ -0,0 +1,30 @@ +namespace StardewModdingAPI.Framework.Networking +{ + internal class MultiplayerPeerMod : IMultiplayerPeerMod + { + /********* + ** Accessors + *********/ + /// <summary>The mod's display name.</summary> + public string Name { get; } + + /// <summary>The unique mod ID.</summary> + public string ID { get; } + + /// <summary>The mod version.</summary> + public ISemanticVersion Version { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod metadata.</param> + public MultiplayerPeerMod(RemoteContextModModel mod) + { + this.Name = mod.Name; + this.ID = mod.ID?.Trim(); + this.Version = mod.Version; + } + } +} diff --git a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs new file mode 100644 index 00000000..9795d971 --- /dev/null +++ b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about an installed mod exchanged with connected computers.</summary> + public class RemoteContextModModel + { + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The unique mod ID.</summary> + public string ID { get; set; } + + /// <summary>The mod version.</summary> + public ISemanticVersion Version { get; set; } + } +} diff --git a/src/SMAPI/Framework/Networking/RemoteContextModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModel.cs new file mode 100644 index 00000000..7befb151 --- /dev/null +++ b/src/SMAPI/Framework/Networking/RemoteContextModel.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>Metadata about the game, SMAPI, and installed mods exchanged with connected computers.</summary> + internal class RemoteContextModel + { + /********* + ** Accessors + *********/ + /// <summary>Whether this player is the host player.</summary> + public bool IsHost { get; set; } + + /// <summary>The game's platform version.</summary> + public GamePlatform Platform { get; set; } + + /// <summary>The installed version of Stardew Valley.</summary> + public ISemanticVersion GameVersion { get; set; } + + /// <summary>The installed version of SMAPI.</summary> + public ISemanticVersion ApiVersion { get; set; } + + /// <summary>The installed mods.</summary> + public RemoteContextModModel[] Mods { get; set; } + } +} diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs b/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs new file mode 100644 index 00000000..fddd423d --- /dev/null +++ b/src/SMAPI/Framework/Networking/SGalaxyNetClient.cs @@ -0,0 +1,52 @@ +using System; +using Galaxy.Api; +using StardewValley.Network; +using StardewValley.SDKs; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer client used to connect to a hosted server. This is an implementation of <see cref="GalaxyNetClient"/> with callbacks for SMAPI functionality.</summary> + internal class SGalaxyNetClient : GalaxyNetClient + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</summary> + private readonly Action<OutgoingMessage, Action<OutgoingMessage>, Action> OnSendingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="address">The remote address being connected.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</param> + /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</param> + public SGalaxyNetClient(GalaxyID address, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage, Action<OutgoingMessage, Action<OutgoingMessage>, Action> onSendingMessage) + : base(address) + { + this.OnProcessingMessage = onProcessingMessage; + this.OnSendingMessage = onSendingMessage; + } + + /// <summary>Send a message to the connected peer.</summary> + public override void sendMessage(OutgoingMessage message) + { + this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Process an incoming network message.</summary> + /// <param name="message">The message to process.</param> + protected override void processIncomingMessage(IncomingMessage message) + { + this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); + } + } +} diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs new file mode 100644 index 00000000..2fc92737 --- /dev/null +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -0,0 +1,63 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Galaxy.Api; +using StardewValley.Network; +using StardewValley.SDKs; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer server used to connect to an incoming player. This is an implementation of <see cref="LidgrenServer"/> that adds support for SMAPI's metadata context exchange.</summary> + internal class SGalaxyNetServer : GalaxyNetServer + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + private readonly SMultiplayer Multiplayer; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="gameServer">The underlying game server.</param> + /// <param name="multiplayer">SMAPI's implementation of the game's core multiplayer logic.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</param> + public SGalaxyNetServer(IGameServer gameServer, SMultiplayer multiplayer, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage) + : base(gameServer) + { + this.Multiplayer = multiplayer; + this.OnProcessingMessage = onProcessingMessage; + } + + /// <summary>Read and process a message from the client.</summary> + /// <param name="peer">The Galaxy peer ID.</param> + /// <param name="messageStream">The data to process.</param> + [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] + protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) + { + using (IncomingMessage message = new IncomingMessage()) + using (BinaryReader reader = new BinaryReader(messageStream)) + { + message.Read(reader); + this.OnProcessingMessage(message, outgoing => this.sendMessage(peer, outgoing), () => + { + if (this.peers.ContainsLeft(message.FarmerID) && (long)this.peers[message.FarmerID] == (long)peer.ToUint64()) + { + this.gameServer.processIncomingMessage(message); + } + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) + { + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + GalaxyID capturedPeer = new GalaxyID(peer.ToUint64()); + this.gameServer.checkFarmhandRequest(Convert.ToString(peer.ToUint64()), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + } + }); + } + } + } +} diff --git a/src/SMAPI/Framework/Networking/SLidgrenClient.cs b/src/SMAPI/Framework/Networking/SLidgrenClient.cs new file mode 100644 index 00000000..02d9d68f --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenClient.cs @@ -0,0 +1,50 @@ +using System; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer client used to connect to a hosted server. This is an implementation of <see cref="LidgrenClient"/> with callbacks for SMAPI functionality.</summary> + internal class SLidgrenClient : LidgrenClient + { + /********* + ** Properties + *********/ + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + /// <summary>A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</summary> + private readonly Action<OutgoingMessage, Action<OutgoingMessage>, Action> OnSendingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="address">The remote address being connected.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send an arbitrary message, and a callback to run the default logic.</param> + /// <param name="onSendingMessage">A callback to raise when sending a message. This receives the outgoing message, a method to send an arbitrary message, and a callback to resume the default logic.</param> + public SLidgrenClient(string address, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage, Action<OutgoingMessage, Action<OutgoingMessage>, Action> onSendingMessage) + : base(address) + { + this.OnProcessingMessage = onProcessingMessage; + this.OnSendingMessage = onSendingMessage; + } + + /// <summary>Send a message to the connected peer.</summary> + public override void sendMessage(OutgoingMessage message) + { + this.OnSendingMessage(message, base.sendMessage, () => base.sendMessage(message)); + } + + + /********* + ** Protected methods + *********/ + /// <summary>Process an incoming network message.</summary> + /// <param name="message">The message to process.</param> + protected override void processIncomingMessage(IncomingMessage message) + { + this.OnProcessingMessage(message, base.sendMessage, () => base.processIncomingMessage(message)); + } + } +} diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs new file mode 100644 index 00000000..251e5268 --- /dev/null +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -0,0 +1,65 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using Lidgren.Network; +using StardewValley.Network; + +namespace StardewModdingAPI.Framework.Networking +{ + /// <summary>A multiplayer server used to connect to an incoming player. This is an implementation of <see cref="LidgrenServer"/> that adds support for SMAPI's metadata context exchange.</summary> + internal class SLidgrenServer : LidgrenServer + { + /********* + ** Properties + *********/ + /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + private readonly SMultiplayer Multiplayer; + + /// <summary>A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</summary> + private readonly Action<IncomingMessage, Action<OutgoingMessage>, Action> OnProcessingMessage; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="multiplayer">SMAPI's implementation of the game's core multiplayer logic.</param> + /// <param name="gameServer">The underlying game server.</param> + /// <param name="onProcessingMessage">A callback to raise when receiving a message. This receives the incoming message, a method to send a message, and a callback to run the default logic.</param> + public SLidgrenServer(IGameServer gameServer, SMultiplayer multiplayer, Action<IncomingMessage, Action<OutgoingMessage>, Action> onProcessingMessage) + : base(gameServer) + { + this.Multiplayer = multiplayer; + this.OnProcessingMessage = onProcessingMessage; + } + + /// <summary>Parse a data message from a client.</summary> + /// <param name="rawMessage">The raw network message to parse.</param> + [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] + protected override void parseDataMessageFromClient(NetIncomingMessage rawMessage) + { + // add hook to call multiplayer core + NetConnection peer = rawMessage.SenderConnection; + using (IncomingMessage message = new IncomingMessage()) + using (Stream readStream = new NetBufferReadStream(rawMessage)) + using (BinaryReader reader = new BinaryReader(readStream)) + { + while (rawMessage.LengthBits - rawMessage.Position >= 8) + { + message.Read(reader); + NetConnection connection = rawMessage.SenderConnection; // don't pass rawMessage into context because it gets reused + this.OnProcessingMessage(message, outgoing => this.sendMessage(connection, outgoing), () => + { + if (this.peers.ContainsLeft(message.FarmerID) && this.peers[message.FarmerID] == peer) + this.gameServer.processIncomingMessage(message); + else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) + { + NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); + this.gameServer.checkFarmhandRequest("", farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + } + }); + } + } + } + } +} diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs new file mode 100644 index 00000000..4b95917b --- /dev/null +++ b/src/SMAPI/Framework/SCore.cs @@ -0,0 +1,1361 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Runtime.ExceptionServices; +using System.Security; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +#if SMAPI_FOR_WINDOWS +using System.Windows.Forms; +#endif +using Newtonsoft.Json; +using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Logging; +using StardewModdingAPI.Framework.Models; +using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Serialisation; +using StardewModdingAPI.Internal; +using StardewModdingAPI.Patches; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; +using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; +using Object = StardewValley.Object; +using ThreadState = System.Threading.ThreadState; + +namespace StardewModdingAPI.Framework +{ + /// <summary>The core class which initialises and manages SMAPI.</summary> + internal class SCore : 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>The core logger and monitor on behalf of the game.</summary> + private readonly Monitor MonitorForGame; + + /// <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 SMAPI configuration settings.</summary> + private readonly SConfig Settings; + + /// <summary>The underlying game instance.</summary> + private SGame GameInstance; + + /// <summary>The underlying content manager.</summary> + private ContentCoordinator ContentCore => this.GameInstance.ContentCore; + + /// <summary>Tracks the installed mods.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private readonly ModRegistry ModRegistry = new ModRegistry(); + + /// <summary>Manages deprecation warnings.</summary> + /// <remarks>This is initialised after the game starts.</remarks> + private readonly DeprecationManager DeprecationManager; + + /// <summary>Manages SMAPI events for mods.</summary> + private readonly EventManager EventManager; + + /// <summary>Whether the game is currently running.</summary> + private bool IsGameRunning; + + /// <summary>Whether the program has been disposed.</summary> + private bool IsDisposed; + + /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> + private readonly Regex[] SuppressConsolePatterns = + { + new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + }; + + /// <summary>The mod toolkit used for generic mod interactions.</summary> + private readonly ModToolkit Toolkit = new ModToolkit(); + + /// <summary>The path to search for mods.</summary> + private readonly string ModsPath; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modsPath">The path to search for mods.</param> + /// <param name="writeToConsole">Whether to output log messages to the console.</param> + public SCore(string modsPath, bool writeToConsole) + { + // init paths + this.VerifyPath(modsPath); + this.VerifyPath(Constants.LogDir); + this.ModsPath = modsPath; + + // init log file + this.PurgeNormalLogs(); + string logPath = this.GetLogPath(); + + // init basics + this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); + this.LogFile = new LogFileManager(logPath); + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + { + WriteToConsole = writeToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; + this.MonitorForGame = this.GetSecondaryMonitor("game"); + this.EventManager = new EventManager(this.Monitor, this.ModRegistry); + this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); + + // redirect direct console output + if (this.MonitorForGame.WriteToConsole) + this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + + // inject deprecation managers + SemanticVersion.DeprecationManager = this.DeprecationManager; + + // init logging + this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); + this.Monitor.Log($"Mods go here: {modsPath}"); + if (modsPath != Constants.DefaultModsPath) + this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); + this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + + // validate platform +#if SMAPI_FOR_WINDOWS + if (Constants.Platform != Platform.Windows) + { + this.Monitor.Log("Oops! You're running Windows, but this version of SMAPI is for Linux or Mac. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } +#else + if (Constants.Platform == Platform.Windows) + { + this.Monitor.Log("Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error); + this.PressAnyKeyToExit(); + return; + } +#endif + + // apply game patches + new GamePatcher(this.Monitor).Apply( + new DialogueErrorPatch(this.MonitorForGame, this.Reflection) + ); + } + + /// <summary>Launch SMAPI.</summary> + [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions + public void RunInteractively() + { + // initialise SMAPI + try + { + // hook up events + ContentEvents.Init(this.EventManager); + ControlEvents.Init(this.EventManager); + GameEvents.Init(this.EventManager); + GraphicsEvents.Init(this.EventManager); + InputEvents.Init(this.EventManager); + LocationEvents.Init(this.EventManager); + MenuEvents.Init(this.EventManager); + MineEvents.Init(this.EventManager); + MultiplayerEvents.Init(this.EventManager); + PlayerEvents.Init(this.EventManager); + SaveEvents.Init(this.EventManager); + SpecialisedEvents.Init(this.EventManager); + TimeEvents.Init(this.EventManager); + + // init JSON parser + JsonConverter[] converters = { + new ColorConverter(), + new PointConverter(), + new RectangleConverter() + }; + foreach (JsonConverter converter in converters) + this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); + + // 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); + + // add more leniant assembly resolvers + AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + + // override game + SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); + this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.DeprecationManager, this.InitialiseAfterGameStart, this.Dispose); + 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 + 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; + } + + // check update marker + if (File.Exists(Constants.UpdateMarker)) + { + string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); + if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) + { + if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) + { + this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); + this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); + this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); + Console.ReadKey(); + } + } + File.Delete(Constants.UpdateMarker); + } + + // 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: https://community.playstarbound.com/threads/108375/.", LogLevel.Error); + this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://log.smapi.io.", 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 + this.Monitor.Log("Starting game...", LogLevel.Debug); + try + { + this.IsGameRunning = true; + StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window + this.GameInstance.Run(); + } + catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) + { + this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); + this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); + this.PressAnyKeyToExit(); + } + catch (Exception ex) + { + this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); + this.PressAnyKeyToExit(); + } + finally + { + this.Dispose(); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + // skip if already disposed + if (this.IsDisposed) + return; + this.IsDisposed = true; + this.Monitor.Log("Disposing...", LogLevel.Trace); + + // dispose mod data + foreach (IModMetadata mod in this.ModRegistry.GetAll()) + { + try + { + (mod.Mod as IDisposable)?.Dispose(); + } + catch (Exception ex) + { + mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); + } + } + + // dispose core components + this.IsGameRunning = false; + this.ConsoleManager?.Dispose(); + this.ContentCore?.Dispose(); + this.CancellationTokenSource?.Dispose(); + this.GameInstance?.Dispose(); + this.LogFile?.Dispose(); + + // end game (moved from Game1.OnExiting to let us clean up first) + Process.GetCurrentProcess().Kill(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Initialise SMAPI and mods after the game starts.</summary> + private void InitialiseAfterGameStart() + { + // add headers + if (this.Settings.DeveloperMode) + 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); + this.Monitor.VerboseLog("Verbose logging enabled."); + + // 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 mod data + ModToolkit toolkit = new ModToolkit(); + ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); + + // load mods + { + this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); + ModResolver resolver = new ModResolver(); + + // load manifests + IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); + + // filter out ignored mods + foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) + this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace); + mods = mods.Where(p => !p.IsIgnored).ToArray(); + + // load mods + resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); + mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); + this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); + + // write metadata file + if (this.Settings.DumpMetadata) + { + ModFolderExport export = new ModFolderExport + { + Exported = DateTime.UtcNow.ToString("O"), + ApiVersion = Constants.ApiVersion.ToString(), + GameVersion = Constants.GameVersion.ToString(), + ModFolderPath = this.ModsPath, + Mods = mods + }; + this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export); + } + + // check for updates + this.CheckForUpdatesAsync(mods); + } + if (this.Monitor.IsExiting) + { + this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); + return; + } + + // update window titles + int modsLoaded = this.ModRegistry.GetAll().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.ContentCore.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) + (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 + this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); + this.GameInstance.CommandManager.Add(null, "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.GameInstance.CommandManager.Add(null, "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; + + // handle command + this.Monitor.LogUserInput(input); + this.GameInstance.CommandQueue.Enqueue(input); + } + }); + 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 < Object.objectInfoDescriptionIndex + 1) + { + LogIssue(entry.Key, "too few fields for an object"); + hasObjectIssues = true; + continue; + } + + // check min length for specific types + switch (fields[Object.objectInfoTypeIndex].Split(new[] { ' ' }, 2)[0]) + { + case "Cooking": + if (fields.Length < Object.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 any installed mods, and print alerts to the console if an update is available.</summary> + /// <param name="mods">The mods to include in the update check (if eligible).</param> + private void CheckForUpdatesAsync(IModMetadata[] mods) + { + if (!this.Settings.CheckForUpdates) + return; + + new Thread(() => + { + // create client + string url = this.Settings.WebApiBaseUrl; +#if !SMAPI_FOR_WINDOWS + url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac +#endif + WebApiClient client = new WebApiClient(url, Constants.ApiVersion); + this.Monitor.Log("Checking for updates...", LogLevel.Trace); + + // check SMAPI version + ISemanticVersion updateFound = null; + try + { + ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; + ISemanticVersion latestStable = response.Main?.Version; + ISemanticVersion latestBeta = response.Optional?.Version; + + if (latestStable == null && response.Errors.Any()) + { + 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.", LogLevel.Warn); + this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) + { + updateFound = latestBeta; + this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); + } + else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) + { + updateFound = latestStable; + this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); + } + else + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? $"Error: {ex.Message}" + : $"Error: {ex.GetLogSummary()}" + ); + } + + // show update message on next launch + if (updateFound != null) + File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); + + // check mod versions + if (mods.Any()) + { + try + { + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); + + // prepare search model + List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>(); + foreach (IModMetadata mod in mods) + { + if (!mod.HasID() || suppressUpdateChecks.Contains(mod.Manifest.UniqueID)) + continue; + + string[] updateKeys = mod + .GetUpdateKeys(validOnly: true) + .Select(p => p.ToString()) + .ToArray(); + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray())); + } + + // fetch results + this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); + + // extract update alerts & errors + var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); + var errors = new StringBuilder(); + foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) + { + // link to update-check data + if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) + continue; + mod.SetUpdateData(result); + + // handle errors + if (result.Errors != null && result.Errors.Any()) + { + errors.AppendLine(result.Errors.Length == 1 + ? $" {mod.DisplayName}: {result.Errors[0]}" + : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}" + ); + } + + // parse versions + bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease(); + ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; + ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; + ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; + ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version; + + // show update alerts + if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) + updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); + else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) + updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); + else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) + updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url)); + } + + // show update errors + if (errors.Length != 0) + this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); + + // show update alerts + if (updates.Any()) + { + this.Monitor.Newline(); + this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); + foreach (var entry in updates) + { + IModMetadata mod = entry.Item1; + ISemanticVersion newVersion = entry.Item2; + string newUrl = entry.Item3; + this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); + } + } + else + this.Monitor.Log(" All mods up to date.", LogLevel.Trace); + } + catch (Exception ex) + { + this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); + this.Monitor.Log(ex is WebException && ex.InnerException == null + ? ex.Message + : ex.ToString() + ); + } + } + }).Start(); + } + + /// <summary>Get whether a given version should be offered to the user as an update.</summary> + /// <param name="currentVersion">The current semantic version.</param> + /// <param name="newVersion">The target semantic version.</param> + /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> + private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + { + return + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); + } + + /// <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) + { + // note: this happens before this.Monitor is initialised + Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}"); + } + } + + /// <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="contentCore">The content manager to use for mod content.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> + private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) + { + this.Monitor.Log("Loading mods...", LogLevel.Trace); + + // load mods + IDictionary<IModMetadata, Tuple<string, string>> skippedMods = new Dictionary<IModMetadata, Tuple<string, string>>(); + using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor)) + { + // init + HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); + InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); + void LogSkip(IModMetadata mod, string errorPhrase, string errorDetails) + { + skippedMods[mod] = Tuple.Create(errorPhrase, errorDetails); + if (mod.Status != ModMetadataStatus.Failed) + mod.SetStatus(ModMetadataStatus.Failed, errorPhrase); + } + + // load mods + foreach (IModMetadata contentPack in mods) + { + if (!this.TryLoadMod(contentPack, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out string errorPhrase, out string errorDetails)) + LogSkip(contentPack, errorPhrase, errorDetails); + } + } + IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); + IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); + + // unlock content packs + this.ModRegistry.AreAllModsLoaded = true; + + // 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 + ); + } + this.Monitor.Newline(); + + // log loaded content packs + if (loadedContentPacks.Any()) + { + string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; + + this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); + foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) + { + IManifest manifest = metadata.Manifest; + this.Monitor.Log( + $" {metadata.DisplayName} {manifest.Version}" + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + LogLevel.Info + ); + } + this.Monitor.Newline(); + } + + // log mod warnings + this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); + + // initialise translations + this.ReloadTranslations(loadedMods); + + // initialise loaded non-content-pack mods + foreach (IModMetadata metadata in loadedMods) + { + // add interceptors + if (metadata.Mod.Helper.Content is ContentHelper helper) + { + // ReSharper disable SuspiciousTypeConversion.Global + if (metadata.Mod is IAssetEditor editor) + helper.ObservableAssetEditors.Add(editor); + if (metadata.Mod is IAssetLoader loader) + helper.ObservableAssetLoaders.Add(loader); + // ReSharper restore SuspiciousTypeConversion.Global + + this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors; + this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders; + } + + // call entry method + try + { + IMod mod = metadata.Mod; + mod.Entry(mod.Helper); + } + catch (Exception ex) + { + metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); + } + + // get mod API + try + { + object api = metadata.Mod.GetApi(); + if (api != null && !api.GetType().IsPublic) + { + api = null; + this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); + } + + if (api != null) + this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); + metadata.SetApi(api); + } + catch (Exception ex) + { + this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {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.ContentCore.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.ContentCore.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 => p.Mod.Helper.Content.AssetEditors).ToArray(); + IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray(); + if (editors.Any() || loaders.Any()) + { + this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); + this.ContentCore.InvalidateCacheFor(editors, loaders); + } + + // unlock mod integrations + this.ModRegistry.AreAllModsInitialised = true; + } + + /// <summary>Load a given mod.</summary> + /// <param name="mod">The mod to load.</param> + /// <param name="mods">The mods being loaded.</param> + /// <param name="assemblyLoader">Preprocesses and loads mod assemblies</param> + /// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param> + /// <param name="jsonHelper">The JSON helper with which to read mods' JSON files.</param> + /// <param name="contentCore">The content manager to use for mod content.</param> + /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> + /// <param name="suppressUpdateChecks">The mod IDs to ignore when validating update keys.</param> + /// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param> + /// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param> + /// <returns>Returns whether the mod was successfully loaded.</returns> + private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out string errorReasonPhrase, out string errorDetails) + { + errorDetails = null; + + // log entry + { + string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath); + if (mod.IsContentPack) + this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace); + else if (mod.Manifest?.EntryDll != null) + this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + else + this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace); + } + + // add warning for missing update key + if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest.UniqueID) && !mod.HasValidUpdateKeys()) + mod.SetWarning(ModWarning.NoUpdateKeys); + + // validate status + if (mod.Status == ModMetadataStatus.Failed) + { + this.Monitor.Log($" Failed: {mod.Error}", LogLevel.Trace); + errorReasonPhrase = mod.Error; + return false; + } + + // add deprecation warning for old version format + { + if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat) + this.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.Notice); + } + + // validate dependencies + // Although dependences are validated before mods are loaded, a dependency may have failed to load. + if (mod.Manifest.Dependencies?.Any() == true) + { + foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) + { + if (this.ModRegistry.Get(dependency.UniqueID) == null) + { + string dependencyName = mods + .FirstOrDefault(otherMod => otherMod.HasID(dependency.UniqueID)) + ?.DisplayName ?? dependency.UniqueID; + errorReasonPhrase = $"it needs the '{dependencyName}' mod, which couldn't be loaded."; + return false; + } + } + } + + // load as content pack + if (mod.IsContentPack) + { + IManifest manifest = mod.Manifest; + IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper); + mod.SetMod(contentPack, monitor); + this.ModRegistry.Add(mod); + + errorReasonPhrase = null; + return true; + } + + // load as mod + else + { + IManifest manifest = mod.Manifest; + + // load mod + string assemblyPath = manifest?.EntryDll != null + ? Path.Combine(mod.DirectoryPath, manifest.EntryDll) + : null; + Assembly modAssembly; + try + { + modAssembly = assemblyLoader.Load(mod, assemblyPath, assumeCompatible: mod.DataRecord?.Status == ModStatus.AssumeCompatible); + } + catch (IncompatibleInstructionException) // details already in trace logs + { + string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://mods.smapi.io" }.Where(p => p != null).ToArray(); + errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}."; + return false; + } + catch (SAssemblyLoadFailedException ex) + { + errorReasonPhrase = $"it DLL couldn't be loaded: {ex.Message}"; + return false; + } + catch (Exception ex) + { + errorReasonPhrase = "its DLL couldn't be loaded."; + errorDetails = $"Error: {ex.GetLogSummary()}"; + return false; + } + + // initialise mod + try + { + // get mod instance + if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase)) + return false; + + // get content packs + IContentPack[] GetContentPacks() + { + if (!this.ModRegistry.AreAllModsLoaded) + throw new InvalidOperationException("Can't access content packs before SMAPI finishes loading mods."); + + return this.ModRegistry + .GetAll(assemblyMods: false) + .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID)) + .Select(p => p.ContentPack) + .ToArray(); + } + + // init mod helpers + IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + IModHelper modHelper; + { + IModEvents events = new ModEvents(mod, this.EventManager); + ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); + IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); + IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); + IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection, this.DeprecationManager); + IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); + ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); + + IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); + } + + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, GetContentPacks, CreateTransitionalContentPack, this.DeprecationManager); + } + + // init mod + modEntry.ModManifest = manifest; + modEntry.Helper = modHelper; + modEntry.Monitor = monitor; + + // track mod + mod.SetMod(modEntry); + this.ModRegistry.Add(mod); + return true; + } + catch (Exception ex) + { + errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}"; + return false; + } + } + } + + /// <summary>Write a summary of mod warnings to the console and log.</summary> + /// <param name="mods">The loaded mods.</param> + /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> + private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) + { + // get mods with warnings + IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); + if (!modsWithWarnings.Any() && !skippedMods.Any()) + return; + + // log intro + { + int count = modsWithWarnings.Union(skippedMods.Keys).Count(); + this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); + } + + // log skipped mods + if (skippedMods.Any()) + { + this.Monitor.Log(" Skipped mods", LogLevel.Error); + this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); + this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); + this.Monitor.Newline(); + + HashSet<string> logged = new HashSet<string>(); + foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) + { + IModMetadata mod = pair.Key; + string errorReason = pair.Value.Item1; + string errorDetails = pair.Value.Item2; + string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}"; + + if (!logged.Add($"{message}|{errorDetails}")) + continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed) + + this.Monitor.Log(message, LogLevel.Error); + if (errorDetails != null) + this.Monitor.Log($" ({errorDetails})", LogLevel.Trace); + } + this.Monitor.Newline(); + } + + // log warnings + if (modsWithWarnings.Any()) + { + // issue block format logic + void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) + { + IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray(); + if (!matches.Any()) + return; + + this.Monitor.Log(" " + heading, logLevel); + this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); + foreach (string line in blurb) + this.Monitor.Log(" " + line, logLevel); + this.Monitor.Newline(); + foreach (IModMetadata match in matches) + this.Monitor.Log($" - {match.DisplayName}", logLevel); + this.Monitor.Newline(); + } + + // supported issues + LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", + "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", + "errors, or crashes in-game." + ); + LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", + "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", + "you uninstall these mods." + ); + if (this.Settings.ParanoidWarnings) + { + LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly", + "These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be", + "legitimate and innocent usage; this warning is meaningless without further investigation.)" + ); + LogWarningGroup(ModWarning.AccessesShell, LogLevel.Warn, "Accesses shell/process directly", + "These mods directly access the OS shell or processes, and you enabled paranoid warnings. (Note that", + "this may be legitimate and innocent usage; this warning is meaningless without further investigation.)" + ); + } + LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", + "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", + "your game has issues, try removing these first. Otherwise you can ignore this warning." + ); + LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", + "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", + "corruption. If your game has issues, try removing these first." + ); + LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", + "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", + "mods. Consider notifying the mod authors about this problem." + ); + LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", + "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." + ); + } + } + + /// <summary>Load a mod's entry class.</summary> + /// <param name="modAssembly">The mod assembly.</param> + /// <param name="mod">The loaded instance.</param> + /// <param name="error">The error indicating why loading failed (if applicable).</param> + /// <returns>Returns whether the mod entry class was successfully loaded.</returns> + private bool TryLoadModEntry(Assembly modAssembly, out Mod mod, out string error) + { + mod = null; + + // find type + TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray(); + if (modEntries.Length == 0) + { + error = $"its DLL has no '{nameof(Mod)}' subclass."; + return false; + } + if (modEntries.Length > 1) + { + error = $"its DLL contains multiple '{nameof(Mod)}' subclasses."; + return false; + } + + // get implementation + mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); + if (mod == null) + { + error = "its entry class couldn't be instantiated."; + return false; + } + + error = null; + return true; + } + + /// <summary>Reload translations for all mods.</summary> + /// <param name="mods">The mods for which to reload translations.</param> + private void ReloadTranslations(IEnumerable<IModMetadata> mods) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; + foreach (IModMetadata metadata in mods) + { + if (metadata.IsContentPack) + throw new InvalidOperationException("Can't reload translations for a content pack."); + + // 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 + { + if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) + translations[locale] = data; + else + metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); + } + catch (Exception ex) + { + metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + } + } + } + + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // skip empty files + if (translations[locale] == null || !translations[locale].Keys.Any()) + { + metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); + translations.Remove(locale); + continue; + } + + // handle duplicates + HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) + { + duplicateKeys.Add(key); + translations[locale].Remove(key); + } + } + if (duplicateKeys.Any()) + metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); + } + + // 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()) + { + Command result = this.GameInstance.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}{(result.Mod != null ? $"\n(Added by {result.Mod.DisplayName}.)" : "")}", LogLevel.Info); + } + else + { + string message = "The following commands are registered:\n"; + IGrouping<string, string>[] groups = (from command in this.GameInstance.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); + foreach (var group in groups) + { + string modName = group.Key ?? "SMAPI"; + 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); + } + break; + + case "reload_i18n": + this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); + 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) + { + // detect exception + LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; + + // ignore suppressed message + if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) + return; + + // forward to monitor + 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); + this.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 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, this.Settings.ColorScheme, this.Settings.VerboseLogging) + { + WriteToConsole = this.Monitor.WriteToConsole, + ShowTraceInConsole = this.Settings.DeveloperMode, + ShowFullStampInConsole = this.Settings.DeveloperMode + }; + } + + /// <summary>Get the absolute path to the next available log file.</summary> + private string GetLogPath() + { + // default path + { + FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); + if (!defaultFile.Exists) + return defaultFile.FullName; + } + + // get first disambiguated path + for (int i = 2; i < int.MaxValue; i++) + { + FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); + if (!file.Exists) + return file.FullName; + } + + // should never happen + throw new InvalidOperationException("Could not find an available log path."); + } + + /// <summary>Delete normal (non-crash) log files created by SMAPI.</summary> + private void PurgeNormalLogs() + { + DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); + if (!logsDir.Exists) + return; + + foreach (FileInfo logFile in logsDir.EnumerateFiles()) + { + // skip non-SMAPI file + if (!logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase)) + continue; + + // skip crash log + if (logFile.FullName == Constants.FatalCrashLog) + continue; + + // delete file + try + { + FileUtilities.ForceDelete(logFile); + } + catch (IOException) + { + // ignore file if it's in use + } + } + } + } +} diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 83e8c9a7..75cf4c52 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -11,9 +11,11 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; using Netcode; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; +using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking; using StardewModdingAPI.Framework.Utilities; @@ -40,12 +42,21 @@ namespace StardewModdingAPI.Framework /**** ** SMAPI state ****/ - /// <summary>Encapsulates monitoring and logging.</summary> + /// <summary>Encapsulates monitoring and logging for SMAPI.</summary> private readonly IMonitor Monitor; + /// <summary>Encapsulates monitoring and logging on the game's behalf.</summary> + private readonly IMonitor MonitorForGame; + /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager Events; + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>Manages deprecation warnings.</summary> + private readonly DeprecationManager DeprecationManager; + /// <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 @@ -74,9 +85,6 @@ namespace StardewModdingAPI.Framework /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; - /**** ** Game state ****/ @@ -114,9 +122,6 @@ namespace StardewModdingAPI.Framework /// <summary>The game's core multiplayer utility.</summary> public SMultiplayer Multiplayer => (SMultiplayer)Game1.multiplayer; - /// <summary>Whether SMAPI should log more information about the game context.</summary> - public bool VerboseLogging { get; set; } - /// <summary>A list of queued commands to execute.</summary> /// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks> public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); @@ -126,13 +131,16 @@ namespace StardewModdingAPI.Framework ** Protected methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> + /// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param> /// <param name="reflection">Simplifies access to private game code.</param> /// <param name="eventManager">Manages SMAPI events for mods.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + /// <param name="deprecationManager">Manages deprecation warnings.</param> /// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param> /// <param name="onGameExiting">A callback to invoke when the game exits.</param> - internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, Action onGameInitialised, Action onGameExiting) + internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialised, Action onGameExiting) { SGame.ConstructorHack = null; @@ -145,13 +153,16 @@ namespace StardewModdingAPI.Framework // init SMAPI this.Monitor = monitor; + this.MonitorForGame = monitorForGame; this.Events = eventManager; + this.ModRegistry = modRegistry; this.Reflection = reflection; - this.JsonHelper = jsonHelper; + this.DeprecationManager = deprecationManager; this.OnGameInitialised = onGameInitialised; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); - Game1.multiplayer = new SMultiplayer(monitor, eventManager); + Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived); + Game1.hooks = new SModHooks(this.OnNewDayAfterFade); // init observables Game1.locations = new ObservableCollection<GameLocation>(); @@ -180,9 +191,21 @@ namespace StardewModdingAPI.Framework this.OnGameExiting?.Invoke(); } - /**** - ** Intercepted methods & events - ****/ + /// <summary>A callback invoked before <see cref="Game1.newDayAfterFade"/> runs.</summary> + protected void OnNewDayAfterFade() + { + this.Events.DayEnding.RaiseEmpty(); + } + + /// <summary>A callback invoked when a mod message is received.</summary> + /// <param name="message">The message to deliver to applicable mods.</param> + private void OnModMessageReceived(ModMessageModel message) + { + // raise events for applicable mods + HashSet<string> modIDs = new HashSet<string>(message.ToModIDs ?? this.ModRegistry.GetAll().Select(p => p.Manifest.UniqueID), StringComparer.InvariantCultureIgnoreCase); + this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + } + /// <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> @@ -214,6 +237,8 @@ namespace StardewModdingAPI.Framework { try { + this.DeprecationManager.PrintQueued(); + /********* ** Special cases *********/ @@ -259,8 +284,10 @@ namespace StardewModdingAPI.Framework // update tick are neglible and not worth the complications of bypassing Game1.Update. if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode) { + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); base.Update(gameTime); - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); return; } @@ -269,14 +296,35 @@ namespace StardewModdingAPI.Framework *********/ while (this.CommandQueue.TryDequeue(out string rawInput)) { + // parse command + string name; + string[] args; + Command command; try { - if (!this.CommandManager.Trigger(rawInput)) + if (!this.CommandManager.TryParse(rawInput, out name, out args, out command)) + { this.Monitor.Log("Unknown command; type 'help' for a list of available commands.", LogLevel.Error); + continue; + } + } + catch (Exception ex) + { + this.Monitor.Log($"Failed parsing that command:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // execute command + try + { + command.Callback.Invoke(name, args); } catch (Exception ex) { - this.Monitor.Log($"The handler registered for that command failed:\n{ex.GetLogSummary()}", LogLevel.Error); + if (command.Mod != null) + command.Mod.LogAsMod($"Mod failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); + else + this.Monitor.Log($"Failed handling that command:\n{ex.GetLogSummary()}", LogLevel.Error); } } @@ -306,7 +354,8 @@ namespace StardewModdingAPI.Framework { this.IsBetweenCreateEvents = true; this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - this.Events.Save_BeforeCreate.Raise(); + this.Events.SaveCreating.RaiseEmpty(); + this.Events.Legacy_BeforeCreateSave.Raise(); } // raise before-save @@ -314,12 +363,15 @@ namespace StardewModdingAPI.Framework { this.IsBetweenSaveEvents = true; this.Monitor.Log("Context: before save.", LogLevel.Trace); - this.Events.Save_BeforeSave.Raise(); + this.Events.Saving.RaiseEmpty(); + this.Events.Legacy_BeforeSave.Raise(); } // suppress non-save events + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); base.Update(gameTime); - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); return; } if (this.IsBetweenCreateEvents) @@ -327,15 +379,19 @@ namespace StardewModdingAPI.Framework // raise after-create this.IsBetweenCreateEvents = false; this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.Events.Save_AfterCreate.Raise(); + this.Events.SaveCreated.RaiseEmpty(); + this.Events.Legacy_AfterCreateSave.Raise(); } 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); - this.Events.Save_AfterSave.Raise(); - this.Events.Time_AfterDayStarted.Raise(); + this.Events.Saved.RaiseEmpty(); + this.Events.DayStarted.RaiseEmpty(); + + this.Events.Legacy_AfterSave.Raise(); + this.Events.Legacy_AfterDayStarted.Raise(); } /********* @@ -365,7 +421,7 @@ namespace StardewModdingAPI.Framework var now = this.Watchers.LocaleWatcher.CurrentValue; this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace); - this.Events.Content_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); + this.Events.Legacy_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); this.Watchers.LocaleWatcher.Reset(); } @@ -376,7 +432,8 @@ namespace StardewModdingAPI.Framework if (wasWorldReady && !Context.IsWorldReady) { this.Monitor.Log("Context: returned to title", LogLevel.Trace); - this.Events.Save_AfterReturnToTitle.Raise(); + this.Events.ReturnedToTitle.RaiseEmpty(); + this.Events.Legacy_AfterReturnToTitle.Raise(); } else if (!this.RaisedAfterLoadEvent && Context.IsWorldReady) { @@ -393,8 +450,11 @@ namespace StardewModdingAPI.Framework // raise events this.RaisedAfterLoadEvent = true; - this.Events.Save_AfterLoad.Raise(); - this.Events.Time_AfterDayStarted.Raise(); + this.Events.SaveLoaded.RaiseEmpty(); + this.Events.DayStarted.RaiseEmpty(); + + this.Events.Legacy_AfterLoad.Raise(); + this.Events.Legacy_AfterDayStarted.Raise(); } /********* @@ -406,9 +466,14 @@ namespace StardewModdingAPI.Framework // since the game adds & removes its own handler on the fly. if (this.Watchers.WindowSizeWatcher.IsChanged) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); - this.Events.Graphics_Resize.Raise(); + + Point oldSize = this.Watchers.WindowSizeWatcher.PreviousValue; + Point newSize = this.Watchers.WindowSizeWatcher.CurrentValue; + + this.Events.WindowResized.Raise(new WindowResizedEventArgs(oldSize, newSize)); + this.Events.Legacy_Resize.Raise(); this.Watchers.WindowSizeWatcher.Reset(); } @@ -430,7 +495,7 @@ namespace StardewModdingAPI.Framework ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue; this.Watchers.CursorWatcher.Reset(); - this.Events.Input_CursorMoved.Raise(new InputCursorMovedEventArgs(was, now)); + this.Events.CursorMoved.Raise(new CursorMovedEventArgs(was, now)); } // raise mouse wheel scrolled @@ -440,9 +505,9 @@ namespace StardewModdingAPI.Framework int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue; this.Watchers.MouseWheelScrollWatcher.Reset(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace); - this.Events.Input_MouseWheelScrolled.Raise(new InputMouseWheelScrolledEventArgs(cursor, was, now)); + this.Events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, was, now)); } // raise input button events @@ -453,55 +518,55 @@ namespace StardewModdingAPI.Framework if (status == InputStatus.Pressed) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); - this.Events.Input_ButtonPressed.Raise(new InputButtonPressedEventArgs(button, cursor, inputState)); - this.Events.Legacy_Input_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); + this.Events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); + this.Events.Legacy_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - this.Events.Legacy_Control_KeyPressed.Raise(new EventArgsKeyPressed(key)); + this.Events.Legacy_KeyPressed.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - this.Events.Legacy_Control_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); + this.Events.Legacy_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); else - this.Events.Legacy_Control_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); + this.Events.Legacy_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); } } else if (status == InputStatus.Released) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); - this.Events.Input_ButtonReleased.Raise(new InputButtonReleasedEventArgs(button, cursor, inputState)); - this.Events.Legacy_Input_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); + this.Events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + this.Events.Legacy_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); // legacy events if (button.TryGetKeyboard(out Keys key)) { if (key != Keys.None) - this.Events.Legacy_Control_KeyReleased.Raise(new EventArgsKeyPressed(key)); + this.Events.Legacy_KeyReleased.Raise(new EventArgsKeyPressed(key)); } else if (button.TryGetController(out Buttons controllerButton)) { if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - this.Events.Legacy_Control_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); + this.Events.Legacy_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); else - this.Events.Legacy_Control_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); + this.Events.Legacy_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); } } } // raise legacy state-changed events if (inputState.RealKeyboard != previousInputState.RealKeyboard) - this.Events.Legacy_Control_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); + this.Events.Legacy_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); if (inputState.RealMouse != previousInputState.RealMouse) - this.Events.Legacy_Control_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); + this.Events.Legacy_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); } } @@ -514,14 +579,15 @@ namespace StardewModdingAPI.Framework IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue; this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace); // raise menu events + this.Events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); if (now != null) - this.Events.Menu_Changed.Raise(new EventArgsClickableMenuChanged(was, now)); + this.Events.Legacy_MenuChanged.Raise(new EventArgsClickableMenuChanged(was, now)); else - this.Events.Menu_Closed.Raise(new EventArgsClickableMenuClosed(was)); + this.Events.Legacy_MenuClosed.Raise(new EventArgsClickableMenuClosed(was)); } /********* @@ -541,15 +607,15 @@ namespace StardewModdingAPI.Framework GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray(); this.Watchers.LocationsWatcher.ResetLocationList(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) { string addedText = this.Watchers.LocationsWatcher.Added.Any() ? string.Join(", ", added.Select(p => p.Name)) : "none"; string removedText = this.Watchers.LocationsWatcher.Removed.Any() ? string.Join(", ", removed.Select(p => p.Name)) : "none"; this.Monitor.Log($"Context: location list changed (added {addedText}; removed {removedText}).", LogLevel.Trace); } - this.Events.World_LocationListChanged.Raise(new WorldLocationListChangedEventArgs(added, removed)); - this.Events.Legacy_Location_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); + this.Events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); + this.Events.Legacy_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); } // raise location contents changed @@ -565,8 +631,8 @@ namespace StardewModdingAPI.Framework Building[] removed = watcher.BuildingsWatcher.Removed.ToArray(); watcher.BuildingsWatcher.Reset(); - this.Events.World_BuildingListChanged.Raise(new WorldBuildingListChangedEventArgs(location, added, removed)); - this.Events.Legacy_Location_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); + this.Events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, added, removed)); + this.Events.Legacy_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); } // debris changed @@ -577,7 +643,7 @@ namespace StardewModdingAPI.Framework Debris[] removed = watcher.DebrisWatcher.Removed.ToArray(); watcher.DebrisWatcher.Reset(); - this.Events.World_DebrisListChanged.Raise(new WorldDebrisListChangedEventArgs(location, added, removed)); + this.Events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, added, removed)); } // large terrain features changed @@ -588,7 +654,7 @@ namespace StardewModdingAPI.Framework LargeTerrainFeature[] removed = watcher.LargeTerrainFeaturesWatcher.Removed.ToArray(); watcher.LargeTerrainFeaturesWatcher.Reset(); - this.Events.World_LargeTerrainFeatureListChanged.Raise(new WorldLargeTerrainFeatureListChangedEventArgs(location, added, removed)); + this.Events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, added, removed)); } // NPCs changed @@ -599,7 +665,7 @@ namespace StardewModdingAPI.Framework NPC[] removed = watcher.NpcsWatcher.Removed.ToArray(); watcher.NpcsWatcher.Reset(); - this.Events.World_NpcListChanged.Raise(new WorldNpcListChangedEventArgs(location, added, removed)); + this.Events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, added, removed)); } // objects changed @@ -610,8 +676,8 @@ namespace StardewModdingAPI.Framework KeyValuePair<Vector2, Object>[] removed = watcher.ObjectsWatcher.Removed.ToArray(); watcher.ObjectsWatcher.Reset(); - this.Events.World_ObjectListChanged.Raise(new WorldObjectListChangedEventArgs(location, added, removed)); - this.Events.Legacy_Location_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); + this.Events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, added, removed)); + this.Events.Legacy_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); } // terrain features changed @@ -622,7 +688,7 @@ namespace StardewModdingAPI.Framework KeyValuePair<Vector2, TerrainFeature>[] removed = watcher.TerrainFeaturesWatcher.Removed.ToArray(); watcher.TerrainFeaturesWatcher.Reset(); - this.Events.World_TerrainFeatureListChanged.Raise(new WorldTerrainFeatureListChangedEventArgs(location, added, removed)); + this.Events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, added, removed)); } } } @@ -637,10 +703,11 @@ namespace StardewModdingAPI.Framework int now = this.Watchers.TimeWatcher.CurrentValue; this.Watchers.TimeWatcher.Reset(); - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace); - this.Events.Time_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); + this.Events.TimeChanged.Raise(new TimeChangedEventArgs(was, now)); + this.Events.Legacy_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); } else this.Watchers.TimeWatcher.Reset(); @@ -648,39 +715,45 @@ namespace StardewModdingAPI.Framework // raise player events if (raiseWorldEvents) { - PlayerTracker curPlayer = this.Watchers.CurrentPlayerTracker; + PlayerTracker playerTracker = this.Watchers.CurrentPlayerTracker; // raise current location changed - if (curPlayer.TryGetNewLocation(out GameLocation newLocation)) + if (playerTracker.TryGetNewLocation(out GameLocation newLocation)) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace); - this.Events.Player_Warped.Raise(new EventArgsPlayerWarped(curPlayer.LocationWatcher.PreviousValue, newLocation)); + + GameLocation oldLocation = playerTracker.LocationWatcher.PreviousValue; + this.Events.Warped.Raise(new WarpedEventArgs(playerTracker.Player, oldLocation, newLocation)); + this.Events.Legacy_PlayerWarped.Raise(new EventArgsPlayerWarped(oldLocation, newLocation)); } // raise player leveled up a skill - foreach (KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>> pair in curPlayer.GetChangedSkills()) + foreach (KeyValuePair<SkillType, IValueWatcher<int>> pair in playerTracker.GetChangedSkills()) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace); - this.Events.Player_LeveledUp.Raise(new EventArgsLevelUp(pair.Key, pair.Value.CurrentValue)); + + this.Events.LevelChanged.Raise(new LevelChangedEventArgs(playerTracker.Player, pair.Key, pair.Value.PreviousValue, pair.Value.CurrentValue)); + this.Events.Legacy_LeveledUp.Raise(new EventArgsLevelUp((EventArgsLevelUp.LevelType)pair.Key, pair.Value.CurrentValue)); } // raise player inventory changed - ItemStackChange[] changedItems = curPlayer.GetInventoryChanges().ToArray(); + ItemStackChange[] changedItems = playerTracker.GetInventoryChanges().ToArray(); if (changedItems.Any()) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); - this.Events.Player_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems.ToList())); + this.Events.InventoryChanged.Raise(new InventoryChangedEventArgs(playerTracker.Player, changedItems)); + this.Events.Legacy_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems)); } // raise mine level changed - if (curPlayer.TryGetNewMineLevel(out int mineLevel)) + if (playerTracker.TryGetNewMineLevel(out int mineLevel)) { - if (this.VerboseLogging) + if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace); - this.Events.Mine_LevelChanged.Raise(new EventArgsMineLevelChanged(curPlayer.MineLevelWatcher.PreviousValue, mineLevel)); + this.Events.Legacy_MineLevelChanged.Raise(new EventArgsMineLevelChanged(playerTracker.MineLevelWatcher.PreviousValue, mineLevel)); } } this.Watchers.CurrentPlayerTracker?.Reset(); @@ -694,8 +767,9 @@ namespace StardewModdingAPI.Framework *********/ this.TicksElapsed++; if (this.TicksElapsed == 1) - this.Events.GameLoop_Launched.Raise(new GameLoopLaunchedEventArgs()); - this.Events.GameLoop_Updating.Raise(new GameLoopUpdatingEventArgs(this.TicksElapsed)); + this.Events.GameLaunched.Raise(new GameLaunchedEventArgs()); + this.Events.UnvalidatedUpdateTicking.Raise(new UnvalidatedUpdateTickingEventArgs(this.TicksElapsed)); + this.Events.UpdateTicking.Raise(new UpdateTickingEventArgs(this.TicksElapsed)); try { this.Input.UpdateSuppression(); @@ -703,29 +777,30 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - this.Monitor.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); } - this.Events.GameLoop_Updated.Raise(new GameLoopUpdatedEventArgs(this.TicksElapsed)); + this.Events.UnvalidatedUpdateTicked.Raise(new UnvalidatedUpdateTickedEventArgs(this.TicksElapsed)); + this.Events.UpdateTicked.Raise(new UpdateTickedEventArgs(this.TicksElapsed)); /********* ** Update events *********/ - this.Events.Specialised_UnvalidatedUpdateTick.Raise(); + this.Events.Legacy_UnvalidatedUpdateTick.Raise(); if (this.TicksElapsed == 1) - this.Events.Game_FirstUpdateTick.Raise(); - this.Events.Game_UpdateTick.Raise(); + this.Events.Legacy_FirstUpdateTick.Raise(); + this.Events.Legacy_UpdateTick.Raise(); if (this.CurrentUpdateTick % 2 == 0) - this.Events.Game_SecondUpdateTick.Raise(); + this.Events.Legacy_SecondUpdateTick.Raise(); if (this.CurrentUpdateTick % 4 == 0) - this.Events.Game_FourthUpdateTick.Raise(); + this.Events.Legacy_FourthUpdateTick.Raise(); if (this.CurrentUpdateTick % 8 == 0) - this.Events.Game_EighthUpdateTick.Raise(); + this.Events.Legacy_EighthUpdateTick.Raise(); if (this.CurrentUpdateTick % 15 == 0) - this.Events.Game_QuarterSecondTick.Raise(); + this.Events.Legacy_QuarterSecondTick.Raise(); if (this.CurrentUpdateTick % 30 == 0) - this.Events.Game_HalfSecondTick.Raise(); + this.Events.Legacy_HalfSecondTick.Raise(); if (this.CurrentUpdateTick % 60 == 0) - this.Events.Game_OneSecondTick.Raise(); + this.Events.Legacy_OneSecondTick.Raise(); this.CurrentUpdateTick += 1; if (this.CurrentUpdateTick >= 60) this.CurrentUpdateTick = 0; @@ -796,32 +871,9 @@ namespace StardewModdingAPI.Framework [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] private void DrawImpl(GameTime gameTime) { - if (Game1.debugMode) - { - if (Game1._fpsStopwatch.IsRunning) - { - float totalSeconds = (float)Game1._fpsStopwatch.Elapsed.TotalSeconds; - Game1._fpsList.Add(totalSeconds); - while (Game1._fpsList.Count >= 120) - Game1._fpsList.RemoveAt(0); - float num = 0.0f; - foreach (float fps in Game1._fpsList) - num += fps; - Game1._fps = (float)(1.0 / ((double)num / (double)Game1._fpsList.Count)); - } - Game1._fpsStopwatch.Restart(); - } - else - { - if (Game1._fpsStopwatch.IsRunning) - Game1._fpsStopwatch.Reset(); - Game1._fps = 0.0f; - Game1._fpsList.Clear(); - } if (Game1._newDayTask != null) { this.GraphicsDevice.Clear(this.bgColor); - //base.Draw(gameTime); } else { @@ -834,18 +886,23 @@ namespace StardewModdingAPI.Framework if (activeClickableMenu != null) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } 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(); } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); + Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -854,7 +911,6 @@ namespace StardewModdingAPI.Framework Game1.overlayMenu.draw(Game1.spriteBatch); Game1.spriteBatch.End(); } - //base.Draw(gameTime); this.renderScreenBuffer(); } else @@ -863,37 +919,49 @@ namespace StardewModdingAPI.Framework if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + + this.Events.Rendering.RaiseEmpty(); try { Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } 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(); } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); 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.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.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.gameMode == (byte)11) { - Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); 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); - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); } else if (Game1.currentMinigame != null) @@ -907,25 +975,27 @@ namespace StardewModdingAPI.Framework } this.drawOverlays(Game1.spriteBatch); this.RaisePostRender(needsNewBatch: true); - 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.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); - } + if ((double)Game1.options.zoomLevel == 1.0) + return; + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); } else if (Game1.showingEndOfNightStuff) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); if (Game1.activeClickableMenu != null) { try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -933,21 +1003,21 @@ namespace StardewModdingAPI.Framework Game1.activeClickableMenu.exitThisMenu(); } } - this.RaisePostRender(); + this.Events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - 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.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); - Game1.spriteBatch.End(); - } + if ((double)Game1.options.zoomLevel == 1.0) + return; + this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); + this.GraphicsDevice.Clear(this.bgColor); + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); + Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.End(); } else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + this.Events.Rendering.RaiseEmpty(); string str1 = ""; for (int index = 0; (double)index < gameTime.TotalGameTime.TotalMilliseconds % 999.0 / 333.0; ++index) str1 += "."; @@ -959,24 +1029,36 @@ namespace StardewModdingAPI.Framework int x = 64; int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height; SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1); + this.Events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); 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.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); 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(); + } //base.Draw(gameTime); } else { + byte batchOpens = 0; // used for rendering event + Microsoft.Xna.Framework.Rectangle rectangle; + Viewport viewport; if (Game1.gameMode == (byte)0) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); } else { @@ -985,6 +1067,8 @@ namespace StardewModdingAPI.Framework 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); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight)); for (int index = 0; index < Game1.currentLightSources.Count; ++index) { @@ -998,21 +1082,32 @@ namespace StardewModdingAPI.Framework Game1.bloom.BeginDraw(); this.GraphicsDevice.Clear(this.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - this.Events.Graphics_OnPreRenderEvent.Raise(); + if (++batchOpens == 1) + this.Events.Rendering.RaiseEmpty(); + this.Events.RenderingWorld.RaiseEmpty(); + this.Events.Legacy_OnPreRenderEvent.Raise(); 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, 4); Game1.currentLocation.drawWater(Game1.spriteBatch); - IEnumerable<Farmer> source = Game1.currentLocation.farmers; + this._farmerShadows.Clear(); if (Game1.currentLocation.currentEvent != null && !Game1.currentLocation.currentEvent.isFestival && Game1.currentLocation.currentEvent.farmerActors.Count > 0) - source = (IEnumerable<Farmer>)Game1.currentLocation.currentEvent.farmerActors; - IEnumerable<Farmer> farmers = source.Where<Farmer>((Func<Farmer, bool>)(farmer => { - if (!farmer.IsLocalPlayer) - return !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden); - return true; - })); + foreach (Farmer farmerActor in Game1.currentLocation.currentEvent.farmerActors) + { + if (farmerActor.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmerActor.hidden)) + this._farmerShadows.Add(farmerActor); + } + } + else + { + foreach (Farmer farmer in Game1.currentLocation.farmers) + { + if (farmer.IsLocalPlayer && Game1.displayFarmer || !(bool)((NetFieldBase<bool, NetBool>)farmer.hidden)) + this._farmerShadows.Add(farmer); + } + } if (!Game1.currentLocation.shouldHideCharacters()) { if (Game1.CurrentEvent == null) @@ -1031,13 +1126,13 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), 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)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } - foreach (Farmer farmer in farmers) + foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation()))) + if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f)); + Vector2 local = Game1.GlobalToLocal(farmerShadow.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; @@ -1046,7 +1141,7 @@ namespace StardewModdingAPI.Framework bounds = Game1.shadowTexture.Bounds; double y = (double)bounds.Center.Y; Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5); + double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.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); @@ -1075,13 +1170,13 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), 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)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } - foreach (Farmer farmer in farmers) + foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation()))) + if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f)); + Vector2 local = Game1.GlobalToLocal(farmerShadow.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; @@ -1090,7 +1185,7 @@ namespace StardewModdingAPI.Framework bounds = Game1.shadowTexture.Bounds; double y = (double)bounds.Center.Y; Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5); + double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.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); @@ -1141,14 +1236,14 @@ namespace StardewModdingAPI.Framework Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); Size size2 = Game1.viewport.Size; if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) - goto label_139; + goto label_129; } else - goto label_139; + goto label_129; } Game1.drawPlayerHeldObject(Game1.player); } - label_139: + label_129: 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 - 38), 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) @@ -1186,9 +1281,23 @@ namespace StardewModdingAPI.Framework 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); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * Game1.currentLocation.LightLevel; + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } if (Game1.screenGlow) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Game1.screenGlowColor * Game1.screenGlowAlpha); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } 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); @@ -1221,34 +1330,77 @@ namespace StardewModdingAPI.Framework 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 && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)) - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.OrangeRed * 0.45f); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.OrangeRed * 0.45f; + spriteBatch.Draw(staminaRect, bounds, color); + } Game1.spriteBatch.End(); } Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.drawGrid) { - int x1 = -Game1.viewport.X % 64; - float num1 = (float)(-Game1.viewport.Y % 64); - int x2 = x1; - while (x2 < Game1.graphics.GraphicsDevice.Viewport.Width) + int num1 = -Game1.viewport.X % 64; + float num2 = (float)(-Game1.viewport.Y % 64); + int num3 = num1; + while (true) { - Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x2, (int)num1, 1, Game1.graphics.GraphicsDevice.Viewport.Height), Color.Red * 0.5f); - x2 += 64; + int num4 = num3; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width1 = viewport.Width; + if (num4 < width1) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + int x = num3; + int y = (int)num2; + int width2 = 1; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width2, height); + Color color = Color.Red * 0.5f; + spriteBatch.Draw(staminaRect, destinationRectangle, color); + num3 += 64; + } + else + break; } - float num2 = num1; - while ((double)num2 < (double)Game1.graphics.GraphicsDevice.Viewport.Height) + float num5 = num2; + while (true) { - Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle(x1, (int)num2, Game1.graphics.GraphicsDevice.Viewport.Width, 1), Color.Red * 0.5f); - num2 += 64f; + double num4 = (double)num5; + viewport = Game1.graphics.GraphicsDevice.Viewport; + double height1 = (double)viewport.Height; + if (num4 < height1) + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D staminaRect = Game1.staminaRect; + int x = num1; + int y = (int)num5; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width = viewport.Width; + int height2 = 1; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, height2); + Color color = Color.Red * 0.5f; + spriteBatch.Draw(staminaRect, destinationRectangle, color); + num5 += 64f; + } + else + break; } } if (Game1.currentBillboard != 0) this.drawBillboard(); if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && !Game1.HostPaused)) { - this.Events.Graphics_OnPreRenderHudEvent.Raise(); + this.Events.RenderingHud.RaiseEmpty(); + this.Events.Legacy_OnPreRenderHudEvent.Raise(); this.drawHUD(); - this.Events.Graphics_OnPostRenderHudEvent.Raise(); + this.Events.RenderedHud.RaiseEmpty(); + this.Events.Legacy_OnPostRenderHudEvent.Raise(); } 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); @@ -1288,13 +1440,34 @@ namespace StardewModdingAPI.Framework if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); if (Game1.isRaining && Game1.currentLocation != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert))) - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Blue * 0.2f); + { + 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)) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + { + SpriteBatch spriteBatch = Game1.spriteBatch; + Texture2D fadeToBlackRect = Game1.fadeToBlackRect; + viewport = Game1.graphics.GraphicsDevice.Viewport; + Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; + Color color = Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + spriteBatch.Draw(fadeToBlackRect, bounds, color); + } else if ((double)Game1.flashAlpha > 0.0) { if (Game1.options.screenFlash) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.White * Math.Min(1f, Game1.flashAlpha)); + { + 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) @@ -1335,9 +1508,11 @@ namespace StardewModdingAPI.Framework { try { - this.Events.Graphics_OnPreRenderGuiEvent.Raise(); + this.Events.RenderingActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPreRenderGuiEvent.Raise(); Game1.activeClickableMenu.draw(Game1.spriteBatch); - this.Events.Graphics_OnPostRenderGuiEvent.Raise(); + this.Events.RenderedActiveMenu.RaiseEmpty(); + this.Events.Legacy_OnPostRenderGuiEvent.Raise(); } catch (Exception ex) { @@ -1352,11 +1527,12 @@ namespace StardewModdingAPI.Framework string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1); } - this.RaisePostRender(); + this.Events.RenderedWorld.RaiseEmpty(); + this.Events.Rendered.RaiseEmpty(); + this.Events.Legacy_OnPostRenderEvent.Raise(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); this.renderScreenBuffer(); - //base.Draw(gameTime); } } } @@ -1377,11 +1553,11 @@ namespace StardewModdingAPI.Framework /// <param name="needsNewBatch">Whether to create a new sprite batch.</param> private void RaisePostRender(bool needsNewBatch = false) { - if (this.Events.Graphics_OnPostRenderEvent.HasListeners()) + if (this.Events.Legacy_OnPostRenderEvent.HasListeners()) { if (needsNewBatch) Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - this.Events.Graphics_OnPostRenderEvent.Raise(); + this.Events.Legacy_OnPostRenderEvent.Raise(); if (needsNewBatch) Game1.spriteBatch.End(); } diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs new file mode 100644 index 00000000..9f0201c8 --- /dev/null +++ b/src/SMAPI/Framework/SModHooks.cs @@ -0,0 +1,34 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Invokes callbacks for mod hooks provided by the game.</summary> + internal class SModHooks : ModHooks + { + /********* + ** Properties + *********/ + /// <summary>A callback to invoke before <see cref="Game1.newDayAfterFade"/> runs.</summary> + private readonly Action BeforeNewDayAfterFade; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="beforeNewDayAfterFade">A callback to invoke before <see cref="Game1.newDayAfterFade"/> runs.</param> + public SModHooks(Action beforeNewDayAfterFade) + { + this.BeforeNewDayAfterFade = beforeNewDayAfterFade; + } + + /// <summary>A hook invoked when <see cref="Game1.newDayAfterFade"/> is called.</summary> + /// <param name="action">The vanilla <see cref="Game1.newDayAfterFade"/> logic.</param> + public override void OnGame1_NewDayAfterFade(Action action) + { + this.BeforeNewDayAfterFade?.Invoke(); + action(); + } + } +} diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 687b1922..629fce1d 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -1,9 +1,33 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Galaxy.Api; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; +using StardewModdingAPI.Framework.Networking; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; using StardewValley; +using StardewValley.Network; +using StardewValley.SDKs; namespace StardewModdingAPI.Framework { /// <summary>SMAPI's implementation of the game's core multiplayer logic.</summary> + /// <remarks> + /// SMAPI syncs mod context to all players through the host as such: + /// 1. Farmhand sends ModContext + PlayerIntro. + /// 2. If host receives ModContext: it stores the context, replies with known contexts, and forwards it to other farmhands. + /// 3. If host receives PlayerIntro before ModContext: it stores a 'vanilla player' context, and forwards it to other farmhands. + /// 4. If farmhand receives ModContext: it stores it. + /// 5. If farmhand receives ServerIntro without a preceding ModContext: it stores a 'vanilla host' context. + /// 6. If farmhand receives PlayerIntro without a preceding ModContext AND it's not the host peer: it stores a 'vanilla player' context. + /// + /// Once a farmhand/server stored a context, messages can be sent to that player through the SMAPI APIs. + /// </remarks> internal class SMultiplayer : Multiplayer { /********* @@ -12,9 +36,31 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + /// <summary>Manages SMAPI events.</summary> private readonly EventManager EventManager; + /// <summary>A callback to invoke when a mod message is received.</summary> + private readonly Action<ModMessageModel> OnModMessageReceived; + + + /********* + ** Accessors + *********/ + /// <summary>The metadata for each connected peer.</summary> + public IDictionary<long, MultiplayerPeer> Peers { get; } = new Dictionary<long, MultiplayerPeer>(); + + /// <summary>The metadata for the host player, if the current player is a farmhand.</summary> + public MultiplayerPeer HostPeer; + /********* ** Public methods @@ -22,26 +68,454 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="eventManager">Manages SMAPI events.</param> - public SMultiplayer(IMonitor monitor, EventManager eventManager) + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + /// <param name="reflection">Simplifies access to private code.</param> + /// <param name="onModMessageReceived">A callback to invoke when a mod message is received.</param> + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived) { this.Monitor = monitor; this.EventManager = eventManager; + this.JsonHelper = jsonHelper; + this.ModRegistry = modRegistry; + this.Reflection = reflection; + this.OnModMessageReceived = onModMessageReceived; } /// <summary>Handle sync messages from other players and perform other initial sync logic.</summary> public override void UpdateEarly() { - this.EventManager.Multiplayer_BeforeMainSync.Raise(); + this.EventManager.Legacy_BeforeMainSync.Raise(); base.UpdateEarly(); - this.EventManager.Multiplayer_AfterMainSync.Raise(); + this.EventManager.Legacy_AfterMainSync.Raise(); } /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary> public override void UpdateLate(bool forceSync = false) { - this.EventManager.Multiplayer_BeforeMainBroadcast.Raise(); + this.EventManager.Legacy_BeforeMainBroadcast.Raise(); base.UpdateLate(forceSync); - this.EventManager.Multiplayer_AfterMainBroadcast.Raise(); + this.EventManager.Legacy_AfterMainBroadcast.Raise(); + } + + /// <summary>Initialise a client before the game connects to a remote server.</summary> + /// <param name="client">The client to initialise.</param> + public override Client InitClient(Client client) + { + switch (client) + { + case LidgrenClient _: + { + string address = this.Reflection.GetField<string>(client, "address").GetValue(); + return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + } + + case GalaxyNetClient _: + { + GalaxyID address = this.Reflection.GetField<GalaxyID>(client, "lobbyId").GetValue(); + return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); + } + + default: + return client; + } + } + + /// <summary>Initialise a server before the game connects to an incoming player.</summary> + /// <param name="server">The server to initialise.</param> + public override Server InitServer(Server server) + { + switch (server) + { + case LidgrenServer _: + { + IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage); + } + + case GalaxyNetServer _: + { + IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage); + } + + default: + return server; + } + } + + /// <summary>A callback raised when sending a message as a farmhand.</summary> + /// <param name="message">The message being sent.</param> + /// <param name="sendMessage">Send an arbitrary message through the client.</param> + /// <param name="resume">Resume sending the underlying message.</param> + protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 1) + case (byte)MessageType.PlayerIntroduction: + sendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + resume(); + break; + + // run default logic + default: + resume(); + break; + } + } + + /// <summary>Process an incoming network message as the host player.</summary> + /// <param name="message">The message to process.</param> + /// <param name="sendMessage">A method which sends the given message to the client.</param> + /// <param name="resume">Process the message using the game's default logic.</param> + public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // sync mod context (step 2) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); + if (this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Rejected mod context from farmhand {message.FarmerID}: already received context for that player.", LogLevel.Error); + return; + } + this.AddPeer(newPeer, canBeHost: false, raiseEvent: false); + + // reply with own context + this.Monitor.VerboseLog(" Replying with host context..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, Game1.player.UniqueMultiplayerID, this.GetContextSyncMessageFields())); + + // reply with other players' context + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + this.Monitor.VerboseLog($" Replying with context for player {otherPeer.PlayerID}..."); + newPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, otherPeer.PlayerID, this.GetContextSyncMessageFields(otherPeer))); + } + + // forward to other peers + if (this.Peers.Count > 1) + { + object[] fields = this.GetContextSyncMessageFields(newPeer); + foreach (MultiplayerPeer otherPeer in this.Peers.Values.Where(p => p.PlayerID != newPeer.PlayerID)) + { + this.Monitor.VerboseLog($" Forwarding context to player {otherPeer.PlayerID}..."); + otherPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModContext, newPeer.PlayerID, fields)); + } + } + + // raise event + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(newPeer)); + } + break; + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + // store peer if new + if (!this.Peers.ContainsKey(message.FarmerID)) + { + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: false); + this.AddPeer(peer, canBeHost: false); + } + + resume(); + break; + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// <summary>Process an incoming network message as a farmhand.</summary> + /// <param name="message">The message to process.</param> + /// <param name="sendMessage">Send an arbitrary message through the client.</param> + /// <param name="resume">Resume processing the message using the game's default logic.</param> + /// <returns>Returns whether the message was handled.</returns> + public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + + switch (message.MessageType) + { + // mod context sync (step 4) + case (byte)MessageType.ModContext: + { + // parse message + RemoteContextModel model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + + // store peer + MultiplayerPeer peer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: model?.IsHost ?? this.HostPeer == null); + if (peer.IsHost && this.HostPeer != null) + { + this.Monitor.Log($"Rejected mod context from host player {peer.PlayerID}: already received host data from {(peer.PlayerID == this.HostPeer.PlayerID ? "that player" : $"player {peer.PlayerID}")}.", LogLevel.Error); + return; + } + this.AddPeer(peer, canBeHost: true); + } + break; + + // handle server intro + case (byte)MessageType.ServerIntroduction: + { + // store peer + if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null) + { + this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace); + this.AddPeer(new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: true), canBeHost: false); + } + resume(); + break; + } + + // handle player intro + case (byte)MessageType.PlayerIntroduction: + { + // store peer + if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer)) + { + peer = new MultiplayerPeer(message.FarmerID, null, sendMessage, isHost: this.HostPeer == null); + this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace); + this.AddPeer(peer, canBeHost: true); + } + + resume(); + break; + } + + // handle mod message + case (byte)MessageType.ModMessage: + this.ReceiveModMessage(message); + break; + + default: + resume(); + break; + } + } + + /// <summary>Remove players who are disconnecting.</summary> + protected override void removeDisconnectedFarmers() + { + foreach (long playerID in this.disconnectingFarmers) + { + if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + { + this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace); + this.Peers.Remove(playerID); + this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); + } + } + + base.removeDisconnectedFarmers(); + } + + /// <summary>Broadcast a mod message to matching players.</summary> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="fromModID">The unique ID of the mod sending the message.</param> + /// <param name="toModIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs) + { + // validate + if (message == null) + throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(messageType)) + throw new ArgumentNullException(nameof(messageType)); + if (string.IsNullOrWhiteSpace(fromModID)) + throw new ArgumentNullException(nameof(fromModID)); + if (!this.Peers.Any()) + { + this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: not connected to any players."); + return; + } + + // filter player IDs + HashSet<long> playerIDs = null; + if (toPlayerIDs != null && toPlayerIDs.Any()) + { + playerIDs = new HashSet<long>(toPlayerIDs); + playerIDs.RemoveWhere(id => !this.Peers.ContainsKey(id)); + if (!playerIDs.Any()) + { + this.Monitor.VerboseLog($"Ignored '{messageType}' broadcast from mod {fromModID}: none of the specified player IDs are connected."); + return; + } + } + + // get data to send + ModMessageModel model = new ModMessageModel( + fromPlayerID: Game1.player.UniqueMultiplayerID, + fromModID: fromModID, + toModIDs: toModIDs, + toPlayerIDs: playerIDs?.ToArray(), + type: messageType, + data: JToken.FromObject(message) + ); + string data = JsonConvert.SerializeObject(model, Formatting.None); + + // log message + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace); + + // send message + if (Context.IsMainPlayer) + { + foreach (MultiplayerPeer peer in this.Peers.Values) + { + if (playerIDs == null || playerIDs.Contains(peer.PlayerID)) + { + model.ToPlayerIDs = new[] { peer.PlayerID }; + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data)); + } + } + } + else if (this.HostPeer != null && this.HostPeer.HasSmapi) + this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); + else + this.Monitor.VerboseLog(" Can't send message because no valid connections were found."); + + } + + + /********* + ** Private methods + *********/ + /// <summary>Save a received peer.</summary> + /// <param name="peer">The peer to add.</param> + /// <param name="canBeHost">Whether to track the peer as the host if applicable.</param> + /// <param name="raiseEvent">Whether to raise the <see cref="Events.EventManager.PeerContextReceived"/> event.</param> + private void AddPeer(MultiplayerPeer peer, bool canBeHost, bool raiseEvent = true) + { + // store + this.Peers[peer.PlayerID] = peer; + if (canBeHost && peer.IsHost) + this.HostPeer = peer; + + // raise event + if (raiseEvent) + this.EventManager.PeerContextReceived.Raise(new PeerContextReceivedEventArgs(peer)); + } + + /// <summary>Read the metadata context for a player.</summary> + /// <param name="reader">The stream reader.</param> + private RemoteContextModel ReadContext(BinaryReader reader) + { + string data = reader.ReadString(); + RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + return model.ApiVersion != null + ? model + : null; // no data available for unmodded players + } + + /// <summary>Receive a mod message sent from another player's mods.</summary> + /// <param name="message">The raw message to parse.</param> + private void ReceiveModMessage(IncomingMessage message) + { + // parse message + string json = message.Reader.ReadString(); + ModMessageModel model = this.JsonHelper.Deserialise<ModMessageModel>(json); + HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs()); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Received message: {json}."); + + // notify local mods + if (playerIDs.Contains(Game1.player.UniqueMultiplayerID)) + this.OnModMessageReceived(model); + + // forward to other players + if (Context.IsMainPlayer && playerIDs.Any(p => p != Game1.player.UniqueMultiplayerID)) + { + ModMessageModel newModel = new ModMessageModel(model); + foreach (long playerID in playerIDs) + { + if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + { + newModel.ToPlayerIDs = new[] { peer.PlayerID }; + this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialise(newModel, Formatting.None))); + } + } + } + } + + /// <summary>Get all connected player IDs, including the current player.</summary> + private IEnumerable<long> GetKnownPlayerIDs() + { + yield return Game1.player.UniqueMultiplayerID; + foreach (long peerID in this.Peers.Keys) + yield return peerID; + } + + /// <summary>Get the fields to include in a context sync message sent to other players.</summary> + private object[] GetContextSyncMessageFields() + { + RemoteContextModel model = new RemoteContextModel + { + IsHost = Context.IsWorldReady && Context.IsMainPlayer, + Platform = Constants.TargetPlatform, + ApiVersion = Constants.ApiVersion, + GameVersion = Constants.GameVersion, + Mods = this.ModRegistry + .GetAll() + .Select(mod => new RemoteContextModModel + { + ID = mod.Manifest.UniqueID, + Name = mod.Manifest.Name, + Version = mod.Manifest.Version + }) + .ToArray() + }; + + return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + } + + /// <summary>Get the fields to include in a context sync message sent to other players.</summary> + /// <param name="peer">The peer whose data to represent.</param> + private object[] GetContextSyncMessageFields(IMultiplayerPeer peer) + { + if (!peer.HasSmapi) + return new object[] { "{}" }; + + RemoteContextModel model = new RemoteContextModel + { + IsHost = peer.IsHost, + Platform = peer.Platform.Value, + ApiVersion = peer.ApiVersion, + GameVersion = peer.GameVersion, + Mods = peer.Mods + .Select(mod => new RemoteContextModModel + { + ID = mod.ID, + Name = mod.Name, + Version = mod.Version + }) + .ToArray() + }; + + return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; } } } diff --git a/src/SMAPI/Framework/Singleton.cs b/src/SMAPI/Framework/Singleton.cs new file mode 100644 index 00000000..399a8bf0 --- /dev/null +++ b/src/SMAPI/Framework/Singleton.cs @@ -0,0 +1,10 @@ +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides singleton instances of a given type.</summary> + /// <typeparam name="T">The instance type.</typeparam> + internal static class Singleton<T> where T : new() + { + /// <summary>The singleton instance.</summary> + public static T Instance { get; } = new T(); + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs index f92edb90..8a841a79 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs @@ -5,7 +5,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { /// <summary>A watcher which detects changes to a Netcode collection.</summary> internal class NetCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue> - where TValue : INetObject<INetSerializable> + where TValue : class, INetObject<INetSerializable> { /********* ** Properties diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index d7a02668..ab4ab0d5 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for a net collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net collection.</param> - public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : INetObject<INetSerializable> + public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> { return new NetCollectionWatcher<T>(collection); } diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs index 3814e534..6a705e50 100644 --- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs +++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; using StardewValley.Locations; +using ChangeType = StardewModdingAPI.Events.ChangeType; namespace StardewModdingAPI.Framework.StateTracking { @@ -40,7 +42,7 @@ namespace StardewModdingAPI.Framework.StateTracking public IValueWatcher<int> MineLevelWatcher { get; } /// <summary>Tracks changes to the player's skill levels.</summary> - public IDictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>> SkillWatchers { get; } + public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; } /********* @@ -57,14 +59,14 @@ namespace StardewModdingAPI.Framework.StateTracking // init trackers this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation); this.MineLevelWatcher = WatcherFactory.ForEquatable(() => this.LastValidLocation is MineShaft mine ? mine.mineLevel : 0); - this.SkillWatchers = new Dictionary<EventArgsLevelUp.LevelType, IValueWatcher<int>> + this.SkillWatchers = new Dictionary<SkillType, IValueWatcher<int>> { - [EventArgsLevelUp.LevelType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), - [EventArgsLevelUp.LevelType.Farming] = WatcherFactory.ForNetValue(player.farmingLevel), - [EventArgsLevelUp.LevelType.Fishing] = WatcherFactory.ForNetValue(player.fishingLevel), - [EventArgsLevelUp.LevelType.Foraging] = WatcherFactory.ForNetValue(player.foragingLevel), - [EventArgsLevelUp.LevelType.Luck] = WatcherFactory.ForNetValue(player.luckLevel), - [EventArgsLevelUp.LevelType.Mining] = WatcherFactory.ForNetValue(player.miningLevel) + [SkillType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), + [SkillType.Farming] = WatcherFactory.ForNetValue(player.farmingLevel), + [SkillType.Fishing] = WatcherFactory.ForNetValue(player.fishingLevel), + [SkillType.Foraging] = WatcherFactory.ForNetValue(player.foragingLevel), + [SkillType.Luck] = WatcherFactory.ForNetValue(player.luckLevel), + [SkillType.Mining] = WatcherFactory.ForNetValue(player.miningLevel) }; // track watchers for convenience @@ -123,7 +125,7 @@ namespace StardewModdingAPI.Framework.StateTracking } /// <summary>Get the player skill levels which changed.</summary> - public IEnumerable<KeyValuePair<EventArgsLevelUp.LevelType, IValueWatcher<int>>> GetChangedSkills() + public IEnumerable<KeyValuePair<SkillType, IValueWatcher<int>>> GetChangedSkills() { return this.SkillWatchers.Where(p => p.Value.IsChanged); } diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs index d9090c08..5a259663 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -1,6 +1,8 @@ +using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; using StardewValley.Buildings; @@ -18,10 +20,10 @@ namespace StardewModdingAPI.Framework.StateTracking private readonly ICollectionWatcher<GameLocation> LocationListWatcher; /// <summary>A lookup of the tracked locations.</summary> - private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(); + private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(new ObjectReferenceComparer<GameLocation>()); /// <summary>A lookup of registered buildings and their indoor location.</summary> - private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(); + private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(new ObjectReferenceComparer<Building>()); /********* @@ -37,10 +39,10 @@ namespace StardewModdingAPI.Framework.StateTracking public IEnumerable<LocationTracker> Locations => this.LocationDict.Values; /// <summary>The locations removed since the last update.</summary> - public ICollection<GameLocation> Added { get; } = new HashSet<GameLocation>(); + public ICollection<GameLocation> Added { get; } = new HashSet<GameLocation>(new ObjectReferenceComparer<GameLocation>()); /// <summary>The locations added since the last update.</summary> - public ICollection<GameLocation> Removed { get; } = new HashSet<GameLocation>(); + public ICollection<GameLocation> Removed { get; } = new HashSet<GameLocation>(new ObjectReferenceComparer<GameLocation>()); /********* diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 15a2b7dd..9ba32394 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -22,11 +22,19 @@ namespace StardewModdingAPI ** Public methods *********/ /// <summary>Read a JSON file from the content pack folder.</summary> - /// <typeparam name="TModel">The model type.</typeparam> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the content pack directory.</param> /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> TModel ReadJsonFile<TModel>(string path) where TModel : class; + /// <summary>Save data to a JSON file in the content pack's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + /// <summary>Load content from the content pack 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="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The local path to a content file relative to the content pack folder.</param> diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs new file mode 100644 index 00000000..6afdc529 --- /dev/null +++ b/src/SMAPI/IDataHelper.cs @@ -0,0 +1,61 @@ +using System; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for reading and storing local mod data.</summary> + public interface IDataHelper + { + /********* + ** Public methods + *********/ + /**** + ** JSON file + ****/ + /// <summary>Read data from a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + TModel ReadJsonFile<TModel>(string path) where TModel : class; + + /// <summary>Save data to a JSON file in the mod's folder.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="path">The file path relative to the mod folder.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> + void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + + /**** + ** Save file + ****/ + /// <summary>Read arbitrary data stored in the current save slot. This is only possible if a save has been loaded.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + TModel ReadSaveData<TModel>(string key) where TModel : class; + + /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> + void WriteSaveData<TModel>(string key, TModel data) where TModel : class; + + + /**** + ** Global app data + ****/ + /// <summary>Read arbitrary data stored on the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> + TModel ReadGlobalData<TModel>(string key) where TModel : class; + + /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> + /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> + /// <param name="key">The unique key identifying the data.</param> + /// <param name="data">The arbitrary data to save.</param> + void WriteGlobalData<TModel>(string key, TModel data) where TModel : class; + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index d7b8c986..e4b5d390 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -17,9 +17,15 @@ namespace StardewModdingAPI [Obsolete("This is an experimental interface which may change at any time. Don't depend on this for released mods.")] IModEvents Events { get; } + /// <summary>An API for managing console commands.</summary> + ICommandHelper ConsoleCommands { get; } + /// <summary>An API for loading content assets.</summary> IContentHelper Content { get; } + /// <summary>An API for reading and writing persistent mod data.</summary> + IDataHelper Data { get; } + /// <summary>An API for checking and changing input state.</summary> IInputHelper Input { get; } @@ -32,9 +38,6 @@ namespace StardewModdingAPI /// <summary>Provides multiplayer utilities.</summary> IMultiplayerHelper Multiplayer { 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; } @@ -61,12 +64,14 @@ namespace StardewModdingAPI /// <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> + [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] 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> + [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; /**** diff --git a/src/SMAPI/IModInfo.cs b/src/SMAPI/IModInfo.cs new file mode 100644 index 00000000..3c85d454 --- /dev/null +++ b/src/SMAPI/IModInfo.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI +{ + /// <summary>Metadata for a loaded mod.</summary> + public interface IModInfo + { + /// <summary>The mod manifest.</summary> + IManifest Manifest { get; } + + /// <summary>Whether the mod is a content pack.</summary> + bool IsContentPack { get; } + } +} diff --git a/src/SMAPI/IModRegistry.cs b/src/SMAPI/IModRegistry.cs index a06e099e..10b3121e 100644 --- a/src/SMAPI/IModRegistry.cs +++ b/src/SMAPI/IModRegistry.cs @@ -6,12 +6,12 @@ namespace StardewModdingAPI public interface IModRegistry : IModLinked { /// <summary>Get metadata for all loaded mods.</summary> - IEnumerable<IManifest> GetAll(); + IEnumerable<IModInfo> 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); + IModInfo Get(string uniqueID); /// <summary>Get whether a mod has been loaded.</summary> /// <param name="uniqueID">The mod's unique ID.</param> diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs index 62c479bc..0f153e10 100644 --- a/src/SMAPI/IMonitor.cs +++ b/src/SMAPI/IMonitor.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI +namespace StardewModdingAPI { /// <summary>Encapsulates monitoring and logging for a given module.</summary> public interface IMonitor @@ -9,6 +9,9 @@ /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> bool IsExiting { get; } + /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> + bool IsVerbose { get; } + /********* ** Methods @@ -18,6 +21,10 @@ /// <param name="level">The log severity level.</param> void Log(string message, LogLevel level = LogLevel.Debug); + /// <summary>Log a message that only appears when <see cref="IsVerbose"/> is enabled.</summary> + /// <param name="message">The message to log.</param> + void VerboseLog(string message); + /// <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/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs index 43a0ac95..4067a676 100644 --- a/src/SMAPI/IMultiplayerHelper.cs +++ b/src/SMAPI/IMultiplayerHelper.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using StardewValley; @@ -11,5 +12,22 @@ namespace StardewModdingAPI /// <summary>Get the locations which are being actively synced from the host.</summary> IEnumerable<GameLocation> GetActiveLocations(); + + /// <summary>Get a connected player.</summary> + /// <param name="id">The player's unique ID.</param> + /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> + IMultiplayerPeer GetConnectedPlayer(long id); + + /// <summary>Get all connected players.</summary> + IEnumerable<IMultiplayerPeer> GetConnectedPlayers(); + + /// <summary>Send a message to mods installed by connected players.</summary> + /// <typeparam name="TMessage">The data type. This can be a class with a default constructor, or a value type.</typeparam> + /// <param name="message">The data to send over the network.</param> + /// <param name="messageType">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> + /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> + /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> + /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> + void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null); } } diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs new file mode 100644 index 00000000..0d4d3261 --- /dev/null +++ b/src/SMAPI/IMultiplayerPeer.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI +{ + /// <summary>Metadata about a connected player.</summary> + public interface IMultiplayerPeer + { + /********* + ** Accessors + *********/ + /// <summary>The player's unique ID.</summary> + long PlayerID { get; } + + /// <summary>Whether this is a connection to the host player.</summary> + bool IsHost { get; } + + /// <summary>Whether the player has SMAPI installed.</summary> + bool HasSmapi { get; } + + /// <summary>The player's OS platform, if <see cref="HasSmapi"/> is true.</summary> + GamePlatform? Platform { get; } + + /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> + ISemanticVersion GameVersion { get; } + + /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> + ISemanticVersion ApiVersion { get; } + + /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> + IEnumerable<IMultiplayerPeerMod> Mods { get; } + + + /********* + ** Methods + *********/ + /// <summary>Get metadata for a mod installed by the player.</summary> + /// <param name="id">The unique mod ID.</param> + /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> + IMultiplayerPeerMod GetMod(string id); + } +} diff --git a/src/SMAPI/IMultiplayerPeerMod.cs b/src/SMAPI/IMultiplayerPeerMod.cs new file mode 100644 index 00000000..005408b1 --- /dev/null +++ b/src/SMAPI/IMultiplayerPeerMod.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI +{ + /// <summary>Metadata about a mod installed by a connected player.</summary> + public interface IMultiplayerPeerMod + { + /// <summary>The mod's display name.</summary> + string Name { get; } + + /// <summary>The unique mod ID.</summary> + string ID { get; } + + /// <summary>The mod version.</summary> + ISemanticVersion Version { get; } + } +} diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 2f0c1b15..ff8d54e3 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -34,20 +34,11 @@ namespace StardewModdingAPI.Metadata // rewrite for crossplatform compatibility new MethodParentRewriter(typeof(SpriteBatch), typeof(SpriteBatchMethods), onlyIfPlatformChanged: true), - // rewrite for SMAPI 2.0 - new VirtualEntryCallRemover(), - - // rewrite for SMAPI 2.6 (types moved into SMAPI.Toolkit.CoreInterfaces) - new TypeReferenceRewriter("StardewModdingAPI.IManifest", typeof(IManifest), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.IManifestContentPackFor", typeof(IManifestContentPackFor), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.IManifestDependency", typeof(IManifestDependency), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - new TypeReferenceRewriter("StardewModdingAPI.ISemanticVersion", typeof(ISemanticVersion), shouldIgnore: type => type.Scope.Name != "StardewModdingAPI"), - // rewrite for Stardew Valley 1.3 new StaticFieldToConstantRewriter<int>(typeof(Game1), "tileSize", Game1.tileSize), /**** - ** detect incompatible code + ** detect mod issues ****/ // detect broken code new ReferenceToMissingMemberFinder(this.ValidateReferencesToAssemblies), @@ -61,7 +52,22 @@ namespace StardewModdingAPI.Metadata 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), - new EventFinder(typeof(SpecialisedEvents).FullName, nameof(SpecialisedEvents.UnvalidatedUpdateTick), InstructionHandleResult.DetectedUnvalidatedUpdateTick) + new EventFinder(typeof(SpecialisedEvents).FullName, nameof(SpecialisedEvents.UnvalidatedUpdateTick), InstructionHandleResult.DetectedUnvalidatedUpdateTick), + + /**** + ** detect paranoid issues + ****/ + // filesystem access + new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.Directory).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.DirectoryInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.DriveInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess), + new TypeFinder(typeof(System.IO.FileSystemWatcher).FullName, InstructionHandleResult.DetectedFilesystemAccess), + + // shell access + new TypeFinder(typeof(System.Diagnostics.Process).FullName, InstructionHandleResult.DetectedShellAccess) }; } } diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs new file mode 100644 index 00000000..d8905fd1 --- /dev/null +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Harmony; +using StardewModdingAPI.Framework.Patching; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Patches +{ + /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> + internal class DialogueErrorPatch : IHarmonyPatch + { + /********* + ** Private methods + *********/ + /// <summary>Writes messages to the console and log file on behalf of the game.</summary> + private static IMonitor MonitorForGame; + + /// <summary>Simplifies access to private code.</summary> + private static Reflector Reflection; + + + /********* + ** Accessors + *********/ + /// <summary>A unique name for this patch.</summary> + public string Name => $"{nameof(DialogueErrorPatch)}"; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param> + /// <param name="reflector">Simplifies access to private code.</param> + public DialogueErrorPatch(IMonitor monitorForGame, Reflector reflector) + { + DialogueErrorPatch.MonitorForGame = monitorForGame; + DialogueErrorPatch.Reflection = reflector; + } + + + /// <summary>Apply the Harmony patch.</summary> + /// <param name="harmony">The Harmony instance.</param> + public void Apply(HarmonyInstance harmony) + { + ConstructorInfo constructor = AccessTools.Constructor(typeof(Dialogue), new[] { typeof(string), typeof(NPC) }); + MethodInfo prefix = AccessTools.Method(this.GetType(), nameof(DialogueErrorPatch.Prefix)); + + harmony.Patch(constructor, new HarmonyMethod(prefix), null); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of the Dialogue constructor.</summary> + /// <param name="__instance">The instance being patched.</param> + /// <param name="masterDialogue">The dialogue being parsed.</param> + /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> + /// <returns>Returns whether to execute the original method.</returns> + /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] + private static bool Prefix(Dialogue __instance, string masterDialogue, NPC speaker) + { + // get private members + bool nameArraysTranslated = DialogueErrorPatch.Reflection.GetField<bool>(typeof(Dialogue), "nameArraysTranslated").GetValue(); + IReflectedMethod translateArraysOfStrings = DialogueErrorPatch.Reflection.GetMethod(typeof(Dialogue), "TranslateArraysOfStrings"); + IReflectedMethod parseDialogueString = DialogueErrorPatch.Reflection.GetMethod(__instance, "parseDialogueString"); + IReflectedMethod checkForSpecialDialogueAttributes = DialogueErrorPatch.Reflection.GetMethod(__instance, "checkForSpecialDialogueAttributes"); + IReflectedField<List<string>> dialogues = DialogueErrorPatch.Reflection.GetField<List<string>>(__instance, "dialogues"); + + // replicate base constructor + if (dialogues.GetValue() == null) + dialogues.SetValue(new List<string>()); + + // duplicate code with try..catch + try + { + if (!nameArraysTranslated) + translateArraysOfStrings.Invoke(); + __instance.speaker = speaker; + parseDialogueString.Invoke(masterDialogue); + checkForSpecialDialogueAttributes.Invoke(); + } + catch (Exception baseEx) when (baseEx.InnerException is TargetInvocationException invocationEx && invocationEx.InnerException is Exception ex) + { + string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; + DialogueErrorPatch.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{ex}", LogLevel.Error); + + parseDialogueString.Invoke("..."); + checkForSpecialDialogueAttributes.Invoke(); + } + + return false; + } + } +} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 634c5066..2efcfecb 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -1,107 +1,26 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Net; using System.Reflection; -using System.Runtime.ExceptionServices; -using System.Security; -using System.Text; -using System.Text.RegularExpressions; using System.Threading; #if SMAPI_FOR_WINDOWS -using System.Windows.Forms; #endif -using Newtonsoft.Json; -using StardewModdingAPI.Events; using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; -using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Framework.Logging; -using StardewModdingAPI.Framework.Models; -using StardewModdingAPI.Framework.ModHelpers; -using StardewModdingAPI.Framework.ModLoading; -using StardewModdingAPI.Framework.Patching; -using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.Serialisation; using StardewModdingAPI.Internal; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; -using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Utilities; -using StardewValley; -using Monitor = StardewModdingAPI.Framework.Monitor; -using SObject = StardewValley.Object; -using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> - internal class Program : IDisposable + internal class Program { /********* ** 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 SMAPI configuration settings.</summary> - private readonly SConfig Settings; - - /// <summary>The underlying game instance.</summary> - private SGame GameInstance; - - /// <summary>The underlying content manager.</summary> - private ContentCoordinator ContentCore => this.GameInstance.ContentCore; - - /// <summary>Tracks the installed mods.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private readonly ModRegistry ModRegistry = new ModRegistry(); - - /// <summary>Manages deprecation warnings.</summary> - /// <remarks>This is initialised after the game starts.</remarks> - private DeprecationManager DeprecationManager; - - /// <summary>Manages SMAPI events for mods.</summary> - private readonly EventManager EventManager; - - /// <summary>Whether the game is currently running.</summary> - private bool IsGameRunning; - - /// <summary>Whether the program has been disposed.</summary> - private bool IsDisposed; - - /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> - private readonly Regex[] SuppressConsolePatterns = - { - new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^Multiplayer auth success$", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput: (?:added CLOUD|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) - }; - - /// <summary>The mod toolkit used for generic mod interactions.</summary> - private readonly ModToolkit Toolkit = new ModToolkit(); - - /// <summary>The path to search for mods.</summary> - private readonly string ModsPath; + /// <summary>The absolute path to search for SMAPI's internal DLLs.</summary> + /// <remarks>We can't use <see cref="Constants.ExecutionPath"/> directly, since <see cref="Constants"/> depends on DLLs loaded from this folder.</remarks> + [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] + internal static readonly string DllSearchPath = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "smapi-internal"); /********* @@ -111,269 +30,47 @@ namespace StardewModdingAPI /// <param name="args">The command-line arguments.</param> public static void Main(string[] args) { - Program.AssertMinimumCompatibility(); - - // get flags from arguments - bool writeToConsole = !args.Contains("--no-terminal"); - - // get mods path from arguments - string modsPath = null; - { - int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; - if (pathIndex >= 1 && args.Length >= pathIndex) - { - modsPath = args[pathIndex]; - if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath)) - modsPath = Path.Combine(Constants.ExecutionPath, modsPath); - } - if (string.IsNullOrWhiteSpace(modsPath)) - modsPath = Constants.DefaultModsPath; - } - - // load SMAPI - using (Program program = new Program(modsPath, writeToConsole)) - program.RunInteractively(); + AppDomain.CurrentDomain.AssemblyResolve += Program.CurrentDomain_AssemblyResolve; + Program.AssertGamePresent(); + Program.AssertGameVersion(); + Program.Start(args); } - /// <summary>Construct an instance.</summary> - /// <param name="modsPath">The path to search for mods.</param> - /// <param name="writeToConsole">Whether to output log messages to the console.</param> - public Program(string modsPath, bool writeToConsole) - { - // init paths - this.VerifyPath(modsPath); - this.VerifyPath(Constants.LogDir); - this.ModsPath = modsPath; - - // init log file - this.PurgeLogFiles(); - string logPath = this.GetLogPath(); - - // init basics - this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); - this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme) - { - WriteToConsole = writeToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - this.EventManager = new EventManager(this.Monitor, this.ModRegistry); - - // init logging - this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); - this.Monitor.Log($"Mods go here: {modsPath}"); - if (modsPath != Constants.DefaultModsPath) - this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); - this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); - - // 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: https://smapi.io.", LogLevel.Error); - this.PressAnyKeyToExit(); - return; - } - - // apply game patches - new GamePatcher(this.Monitor).Apply( - // new GameLocationPatch() - ); - } - /// <summary>Launch SMAPI.</summary> - [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions - public void RunInteractively() + /********* + ** Private methods + *********/ + /// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary> + /// <param name="sender">The event sender.</param> + /// <param name="e">The event arguments.</param> + private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e) { - // initialise SMAPI try { - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - MultiplayerEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); - - // init JSON parser - JsonConverter[] converters = { - new ColorConverter(), - new PointConverter(), - new RectangleConverter() - }; - foreach (JsonConverter converter in converters) - this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); - - // 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); - - // add more leniant assembly resolvers - AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); - - // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); - this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.InitialiseAfterGameStart, this.Dispose); - StardewValley.Program.gamePtr = this.GameInstance; - - // add exit handler - new Thread(() => + AssemblyName name = new AssemblyName(e.Name); + foreach (FileInfo dll in new DirectoryInfo(Program.DllSearchPath).EnumerateFiles("*.dll")) { - 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 - 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; - } - - // check update marker - if (File.Exists(Constants.UpdateMarker)) - { - string rawUpdateFound = File.ReadAllText(Constants.UpdateMarker); - if (SemanticVersion.TryParse(rawUpdateFound, out ISemanticVersion updateFound)) - { - if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) - { - this.Monitor.Log("A new version of SMAPI was detected last time you played.", LogLevel.Error); - this.Monitor.Log($"You can update to {updateFound}: https://smapi.io.", LogLevel.Error); - this.Monitor.Log("Press any key to continue playing anyway. (This only appears when using a SMAPI beta.)", LogLevel.Info); - Console.ReadKey(); - } + if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.InvariantCultureIgnoreCase)) + return Assembly.LoadFrom(dll.FullName); } - File.Delete(Constants.UpdateMarker); - } - - // 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 share your SMAPI log: https://log.smapi.io.", 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 - this.Monitor.Log("Starting game...", LogLevel.Debug); - try - { - this.IsGameRunning = true; - StardewValley.Program.releaseBuild = true; // game's debug logic interferes with SMAPI opening the game window - this.GameInstance.Run(); - } - catch (InvalidOperationException ex) when (ex.Source == "Microsoft.Xna.Framework.Xact" && ex.StackTrace.Contains("Microsoft.Xna.Framework.Audio.AudioEngine..ctor")) - { - this.Monitor.Log("The game couldn't load audio. Do you have speakers or headphones plugged in?", LogLevel.Error); - this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace); - this.PressAnyKeyToExit(); + return null; } catch (Exception ex) { - this.Monitor.Log($"The game failed unexpectedly: {ex.GetLogSummary()}", LogLevel.Error); - this.PressAnyKeyToExit(); - } - finally - { - this.Dispose(); - } - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - // skip if already disposed - if (this.IsDisposed) - return; - this.IsDisposed = true; - this.Monitor.Log("Disposing...", LogLevel.Trace); - - // dispose mod data - foreach (IModMetadata mod in this.ModRegistry.GetAll()) - { - try - { - (mod.Mod as IDisposable)?.Dispose(); - } - catch (Exception ex) - { - mod.LogAsMod($"Mod failed during disposal: {ex.GetLogSummary()}.", LogLevel.Warn); - } + Console.WriteLine($"Error resolving assembly: {ex}"); + return null; } - - // dispose core components - this.IsGameRunning = false; - this.ConsoleManager?.Dispose(); - this.ContentCore?.Dispose(); - this.CancellationTokenSource?.Dispose(); - this.GameInstance?.Dispose(); - this.LogFile?.Dispose(); - - // end game (moved from Game1.OnExiting to let us clean up first) - Process.GetCurrentProcess().Kill(); } - - /********* - ** Private methods - *********/ - /// <summary>Assert that the minimum conditions are present to initialise SMAPI without type load exceptions.</summary> - private static void AssertMinimumCompatibility() + /// <summary>Assert that the game is available.</summary> + /// <remarks>This must be checked *before* any references to <see cref="Constants"/>, and this method should not reference <see cref="Constants"/> itself to avoid errors in Mono.</remarks> + private static void AssertGamePresent() { - void PrintErrorAndExit(string message) - { - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine(message); - Console.ResetColor(); - Program.PressAnyKeyToExit(showMessage: true); - } - string gameAssemblyName = Constants.GameAssemblyName; - - // game not present + Platform platform = EnvironmentUtility.DetectPlatform(); + string gameAssemblyName = platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) { - PrintErrorAndExit( + Program.PrintErrorAndExit( "Oops! SMAPI can't find the game. " + (Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Windows")) || Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Mono")) ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " @@ -382,890 +79,62 @@ namespace StardewModdingAPI + "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.GameInstance.VerboseLogging = this.Settings.VerboseLogging; - - // load core components - this.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry); - - // redirect direct console output - { - Monitor monitor = this.GetSecondaryMonitor("game"); - if (monitor.WriteToConsole) - this.ConsoleManager.OnMessageIntercepted += message => this.HandleConsoleMessage(monitor, message); - } - - // add headers - if (this.Settings.DeveloperMode) - 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); - this.VerboseLog("Verbose logging enabled."); - - // 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 mod data - ModToolkit toolkit = new ModToolkit(); - ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); - - // load mods - { - this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); - ModResolver resolver = new ModResolver(); - - // load manifests - IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); - resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); - - // process dependencies - mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); - - // load mods - this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); - - // write metadata file - if (this.Settings.DumpMetadata) - { - ModFolderExport export = new ModFolderExport - { - Exported = DateTime.UtcNow.ToString("O"), - ApiVersion = Constants.ApiVersion.ToString(), - GameVersion = Constants.GameVersion.ToString(), - ModFolderPath = this.ModsPath, - Mods = mods - }; - this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export); - } - - // check for updates - this.CheckForUpdatesAsync(mods); - } - if (this.Monitor.IsExiting) - { - this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); - return; - } - - // update window titles - int modsLoaded = this.ModRegistry.GetAll().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.ContentCore.GetLocale(); - LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; - - // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) - (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 - this.Monitor.Log("Type 'help' for help, or 'help <cmd>' for a command's usage", LogLevel.Info); - this.GameInstance.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.GameInstance.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; - - // handle command - this.Monitor.LogUserInput(input); - this.GameInstance.CommandQueue.Enqueue(input); - } - }); - 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 any installed mods, and print alerts to the console if an update is available.</summary> - /// <param name="mods">The mods to include in the update check (if eligible).</param> - private void CheckForUpdatesAsync(IModMetadata[] mods) + /// <summary>Assert that the game version is within <see cref="Constants.MinimumGameVersion"/> and <see cref="Constants.MaximumGameVersion"/>.</summary> + private static void AssertGameVersion() { - if (!this.Settings.CheckForUpdates) - return; - - new Thread(() => - { - // create client - string url = this.Settings.WebApiBaseUrl; -#if !SMAPI_FOR_WINDOWS - url = url.Replace("https://", "http://"); // workaround for OpenSSL issues with the game's bundled Mono on Linux/Mac -#endif - WebApiClient client = new WebApiClient(url, Constants.ApiVersion); - this.Monitor.Log("Checking for updates...", LogLevel.Trace); - - // check SMAPI version - ISemanticVersion updateFound = null; - try - { - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; - ISemanticVersion latestStable = response.Main?.Version; - ISemanticVersion latestBeta = response.Optional?.Version; - - if (latestStable == null && response.Errors.Any()) - { - 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.", LogLevel.Warn); - this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) - { - updateFound = latestBeta; - this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) - { - updateFound = latestStable; - this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? $"Error: {ex.Message}" - : $"Error: {ex.GetLogSummary()}" - ); - } - - // show update message on next launch - if (updateFound != null) - File.WriteAllText(Constants.UpdateMarker, updateFound.ToString()); - - // check mod versions - if (mods.Any()) - { - try - { - HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); - - // prepare search model - List<ModSearchEntryModel> searchMods = new List<ModSearchEntryModel>(); - foreach (IModMetadata mod in mods) - { - if (!mod.HasID()) - continue; - - string[] updateKeys = mod.Manifest.UpdateKeys ?? new string[0]; - searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.Except(suppressUpdateChecks).ToArray())); - } - - // fetch results - this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); - IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); - - // extract update alerts & errors - var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); - var errors = new StringBuilder(); - foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) - { - // link to update-check data - if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) - continue; - mod.SetUpdateData(result); - - // handle errors - if (result.Errors != null && result.Errors.Any()) - { - errors.AppendLine(result.Errors.Length == 1 - ? $" {mod.DisplayName}: {result.Errors[0]}" - : $" {mod.DisplayName}:\n - {string.Join("\n - ", result.Errors)}" - ); - } - - // parse versions - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; - ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; - ISemanticVersion unofficialVersion = result.Unofficial?.Version; - - // show update alerts - if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) - updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); - else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) - updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); - else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) - updates.Add(Tuple.Create(mod, unofficialVersion, result.Unofficial?.Url)); - } - - // show update errors - if (errors.Length != 0) - this.Monitor.Log("Got update-check errors for some mods:\n" + errors.ToString().TrimEnd(), LogLevel.Trace); - - // show update alerts - if (updates.Any()) - { - this.Monitor.Newline(); - this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); - foreach (var entry in updates) - { - IModMetadata mod = entry.Item1; - ISemanticVersion newVersion = entry.Item2; - string newUrl = entry.Item3; - this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); - } - } - else - this.Monitor.Log(" All mods up to date.", LogLevel.Trace); - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for new mod versions. This won't affect your game, but you won't be notified of mod updates if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? ex.Message - : ex.ToString() - ); - } - } - }).Start(); - } - - /// <summary>Get whether a given version should be offered to the user as an update.</summary> - /// <param name="currentVersion">The current semantic version.</param> - /// <param name="newVersion">The target semantic version.</param> - /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) - { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); - } - - /// <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="contentCore">The content manager to use for mod content.</param> - /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> - private void LoadMods(IModMetadata[] mods, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase) - { - this.Monitor.Log("Loading mods...", LogLevel.Trace); - - HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.InvariantCultureIgnoreCase); - IDictionary<IModMetadata, string[]> skippedMods = new Dictionary<IModMetadata, string[]>(); - void TrackSkip(IModMetadata mod, string userReasonPhrase, string devReasonPhrase = null) => skippedMods[mod] = new[] { userReasonPhrase, devReasonPhrase }; - - // load content packs - foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) - { - this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)})...", LogLevel.Trace); - - // show warning for missing update key - if (metadata.HasManifest() && !metadata.HasUpdateKeys()) - metadata.SetWarning(ModWarning.NoUpdateKeys); - - // validate status - if (metadata.Status == ModMetadataStatus.Failed) - { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); - TrackSkip(metadata, metadata.Error); - continue; - } - - // load mod as content pack - IManifest manifest = metadata.Manifest; - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - IContentHelper contentHelper = new ContentHelper(this.ContentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IContentPack contentPack = new ContentPack(metadata.DirectoryPath, manifest, contentHelper, jsonHelper); - metadata.SetMod(contentPack, monitor); - this.ModRegistry.Add(metadata); - } - IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); - - // load mods - { - // get content packs by mod ID - IDictionary<string, IContentPack[]> contentPacksByModID = - loadedContentPacks - .GroupBy(p => p.Manifest.ContentPackFor.UniqueID, StringComparer.InvariantCultureIgnoreCase) - .ToDictionary( - group => group.Key, - group => group.Select(metadata => metadata.ContentPack).ToArray(), - StringComparer.InvariantCultureIgnoreCase - ); - - // load mods from metadata - using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor)) - { - InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); - foreach (IModMetadata metadata in mods.Where(p => !p.IsContentPack)) - { - // get basic info - IManifest manifest = metadata.Manifest; - this.Monitor.Log(metadata.Manifest?.EntryDll != null - ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(this.ModsPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid - : $" {metadata.DisplayName}...", LogLevel.Trace); - - // show warnings - if (metadata.HasManifest() && !metadata.HasUpdateKeys() && !suppressUpdateChecks.Contains(metadata.Manifest.UniqueID)) - metadata.SetWarning(ModWarning.NoUpdateKeys); - - // validate status - if (metadata.Status == ModMetadataStatus.Failed) - { - this.Monitor.Log($" Failed: {metadata.Error}", LogLevel.Trace); - TrackSkip(metadata, metadata.Error); - continue; - } - - // load mod - string assemblyPath = metadata.Manifest?.EntryDll != null - ? Path.Combine(metadata.DirectoryPath, metadata.Manifest.EntryDll) - : null; - Assembly modAssembly; - try - { - modAssembly = modAssemblyLoader.Load(metadata, assemblyPath, assumeCompatible: metadata.DataRecord?.Status == ModStatus.AssumeCompatible); - } - catch (IncompatibleInstructionException) // details already in trace logs - { - string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(metadata.Manifest.UniqueID), "https://smapi.io/compat" }.Where(p => p != null).ToArray(); - - TrackSkip(metadata, $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}."); - continue; - } - catch (SAssemblyLoadFailedException ex) - { - TrackSkip(metadata, $"it DLL couldn't be loaded: {ex.Message}"); - continue; - } - catch (Exception ex) - { - TrackSkip(metadata, "its DLL couldn't be loaded.", $"Error: {ex.GetLogSummary()}"); - continue; - } - - // initialise mod - try - { - // get mod instance - if (!this.TryLoadModEntry(modAssembly, error => TrackSkip(metadata, error), out Mod mod)) - continue; - - // get content packs - if (!contentPacksByModID.TryGetValue(manifest.UniqueID, out IContentPack[] contentPacks)) - contentPacks = new IContentPack[0]; - - // init mod helpers - IMonitor monitor = this.GetSecondaryMonitor(metadata.DisplayName); - IModHelper modHelper; - { - IModEvents events = new ModEvents(metadata, this.EventManager); - ICommandHelper commandHelper = new CommandHelper(manifest.UniqueID, metadata.DisplayName, this.GameInstance.CommandManager); - IContentHelper contentHelper = new ContentHelper(contentCore, metadata.DirectoryPath, manifest.UniqueID, metadata.DisplayName, monitor); - IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, metadata.DisplayName, this.Reflection, this.DeprecationManager); - IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateTransitionalContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); - } - - modHelper = new ModHelper(manifest.UniqueID, metadata.DirectoryPath, jsonHelper, this.GameInstance.Input, events, contentHelper, commandHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper, contentPacks, CreateTransitionalContentPack, this.DeprecationManager); - } - - // init mod - mod.ModManifest = manifest; - mod.Helper = modHelper; - mod.Monitor = monitor; - - // track mod - metadata.SetMod(mod); - this.ModRegistry.Add(metadata); - } - catch (Exception ex) - { - TrackSkip(metadata, $"initialisation failed:\n{ex.GetLogSummary()}"); - } - } - } - } - IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); - - // log loaded mods - this.Monitor.Log($"Loaded {loadedMods.Length} mods" + (loadedMods.Length > 0 ? ":" : "."), LogLevel.Info); - foreach (IModMetadata metadata in loadedMods.OrderBy(p => p.DisplayName)) + // min version + if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) { - 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 + ISemanticVersion suggestedApiVersion = Constants.GetCompatibleApiVersion(Constants.GameVersion); + Program.PrintErrorAndExit(suggestedApiVersion != null + ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. You can install SMAPI {suggestedApiVersion} instead to fix this error, or update your game to the latest version." + : $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." ); } - this.Monitor.Newline(); - - // log loaded content packs - if (loadedContentPacks.Any()) - { - string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => id != null && id.Equals(p.Manifest?.UniqueID, StringComparison.InvariantCultureIgnoreCase))?.DisplayName; - - this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); - foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) - { - IManifest manifest = metadata.Manifest; - this.Monitor.Log( - $" {metadata.DisplayName} {manifest.Version}" - + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") - + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), - LogLevel.Info - ); - } - this.Monitor.Newline(); - } - - // log mod warnings - this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); - - // initialise translations - this.ReloadTranslations(loadedMods); - // initialise loaded non-content-pack mods - foreach (IModMetadata metadata in loadedMods) - { - // add interceptors - if (metadata.Mod.Helper.Content is ContentHelper helper) - { - // ReSharper disable SuspiciousTypeConversion.Global - if (metadata.Mod is IAssetEditor editor) - helper.ObservableAssetEditors.Add(editor); - if (metadata.Mod is IAssetLoader loader) - helper.ObservableAssetLoaders.Add(loader); - // ReSharper restore SuspiciousTypeConversion.Global - - this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors; - this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders; - } - - // call entry method - try - { - IMod mod = metadata.Mod; - mod.Entry(mod.Helper); - } - catch (Exception ex) - { - metadata.LogAsMod($"Mod crashed on entry and might not work correctly. Technical details:\n{ex.GetLogSummary()}", LogLevel.Error); - } - - // get mod API - try - { - object api = metadata.Mod.GetApi(); - if (api != null && !api.GetType().IsPublic) - { - api = null; - this.Monitor.Log($"{metadata.DisplayName} provides an API instance with a non-public type. This isn't currently supported, so the API won't be available to other mods.", LogLevel.Warn); - } + // max version + else if (Constants.MaximumGameVersion != null && Constants.GameVersion.IsNewerThan(Constants.MaximumGameVersion)) + Program.PrintErrorAndExit($"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: https://smapi.io."); - if (api != null) - this.Monitor.Log($" Found mod-provided API ({api.GetType().FullName}).", LogLevel.Trace); - metadata.SetApi(api); - } - catch (Exception ex) - { - this.Monitor.Log($"Failed loading mod-provided API for {metadata.DisplayName}. Integrations with other mods may not work. Error: {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.ContentCore.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.ContentCore.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 => p.Mod.Helper.Content.AssetEditors).ToArray(); - IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray(); - if (editors.Any() || loaders.Any()) - { - this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace); - this.ContentCore.InvalidateCacheFor(editors, loaders); - } - - // unlock mod integrations - this.ModRegistry.AreAllModsInitialised = true; } - /// <summary>Write a summary of mod warnings to the console and log.</summary> - /// <param name="mods">The loaded mods.</param> - /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, string[]> skippedMods) - { - // get mods with warnings - IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); - if (!modsWithWarnings.Any() && !skippedMods.Any()) - return; - - // log intro - { - int count = modsWithWarnings.Union(skippedMods.Keys).Count(); - this.Monitor.Log($"Found {count} mod{(count == 1 ? "" : "s")} with warnings:", LogLevel.Info); - } - - // log skipped mods - if (skippedMods.Any()) - { - this.Monitor.Log(" Skipped mods", LogLevel.Error); - this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); - this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); - this.Monitor.Newline(); - - foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) - { - IModMetadata mod = pair.Key; - string[] reason = pair.Value; - - this.Monitor.Log($" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {reason[0]}", LogLevel.Error); - if (reason[1] != null) - this.Monitor.Log($" ({reason[1]})", LogLevel.Trace); - } - this.Monitor.Newline(); - } - - // log warnings - if (modsWithWarnings.Any()) - { - // issue block format logic - void LogWarningGroup(ModWarning warning, LogLevel logLevel, string heading, params string[] blurb) - { - IModMetadata[] matches = modsWithWarnings.Where(p => p.Warnings.HasFlag(warning)).ToArray(); - if (!matches.Any()) - return; - - this.Monitor.Log(" " + heading, logLevel); - this.Monitor.Log(" " + "".PadRight(50, '-'), logLevel); - foreach (string line in blurb) - this.Monitor.Log(" " + line, logLevel); - this.Monitor.Newline(); - foreach (IModMetadata match in matches) - this.Monitor.Log($" - {match.DisplayName}", logLevel); - this.Monitor.Newline(); - } - - // supported issues - LogWarningGroup(ModWarning.BrokenCodeLoaded, LogLevel.Error, "Broken mods", - "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", - "errors, or crashes in-game." - ); - LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", - "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", - "you uninstall these mods." - ); - LogWarningGroup(ModWarning.PatchesGame, LogLevel.Info, "Patched game code", - "These mods directly change the game code. They're more likely to cause errors or bugs in-game; if", - "your game has issues, try removing these first. Otherwise you can ignore this warning." - ); - LogWarningGroup(ModWarning.UsesUnvalidatedUpdateTick, LogLevel.Info, "Bypassed safety checks", - "These mods bypass SMAPI's normal safety checks, so they're more likely to cause errors or save", - "corruption. If your game has issues, try removing these first." - ); - LogWarningGroup(ModWarning.NoUpdateKeys, LogLevel.Debug, "No update keys", - "These mods have no update keys in their manifest. SMAPI may not notify you about updates for these", - "mods. Consider notifying the mod authors about this problem." - ); - LogWarningGroup(ModWarning.UsesDynamic, LogLevel.Debug, "Not crossplatform", - "These mods use the 'dynamic' keyword, and won't work on Linux/Mac." - ); - } - } - - /// <summary>Load a mod's entry class.</summary> - /// <param name="modAssembly">The mod assembly.</param> - /// <param name="onError">A callback invoked when loading fails.</param> - /// <param name="mod">The loaded instance.</param> - private bool TryLoadModEntry(Assembly modAssembly, Action<string> onError, out Mod mod) + /// <summary>Initialise SMAPI and launch the game.</summary> + /// <param name="args">The command-line arguments.</param> + /// <remarks>This method is separate from <see cref="Main"/> because that can't contain any references to assemblies loaded by <see cref="CurrentDomain_AssemblyResolve"/> (e.g. via <see cref="Constants"/>), or Mono will incorrectly show an assembly resolution error before assembly resolution is set up.</remarks> + private static void Start(string[] args) { - mod = null; - - // find type - TypeInfo[] modEntries = modAssembly.DefinedTypes.Where(type => typeof(Mod).IsAssignableFrom(type) && !type.IsAbstract).Take(2).ToArray(); - if (modEntries.Length == 0) - { - onError($"its DLL has no '{nameof(Mod)}' subclass."); - return false; - } - if (modEntries.Length > 1) - { - onError($"its DLL contains multiple '{nameof(Mod)}' subclasses."); - return false; - } - - // get implementation - mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); - if (mod == null) - { - onError("its entry class couldn't be instantiated."); - return false; - } - - return true; - } + // get flags from arguments + bool writeToConsole = !args.Contains("--no-terminal"); - /// <summary>Reload translations for all mods.</summary> - /// <param name="mods">The mods for which to reload translations.</param> - private void ReloadTranslations(IEnumerable<IModMetadata> mods) - { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; - foreach (IModMetadata metadata in mods) + // get mods path from arguments + string modsPath = null; { - if (metadata.IsContentPack) - throw new InvalidOperationException("Can't reload translations for a content pack."); - - // 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 - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed."); - } - catch (Exception ex) - { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}"); - } - } - } - - // validate translations - foreach (string locale in translations.Keys.ToArray()) + int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) - { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); - continue; - } - - // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) - { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } - } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); + modsPath = args[pathIndex]; + if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath)) + modsPath = Path.Combine(Constants.ExecutionPath, modsPath); } - - // 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()) - { - Command result = this.GameInstance.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 - { - string message = "The following commands are registered:\n"; - IGrouping<string, string>[] groups = (from command in this.GameInstance.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); - } - break; - - case "reload_i18n": - this.ReloadTranslations(this.ModRegistry.GetAll(contentPacks: false)); - 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}'."); + if (string.IsNullOrWhiteSpace(modsPath)) + modsPath = Constants.DefaultModsPath; } - } - - /// <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) - { - // detect exception - LogLevel level = message.Contains("Exception") ? LogLevel.Error : LogLevel.Trace; - - // ignore suppressed message - if (level != LogLevel.Error && this.SuppressConsolePatterns.Any(p => p.IsMatch(message))) - return; - // forward to monitor - monitor.Log(message, level); + // load SMAPI + using (SCore core = new SCore(modsPath, writeToConsole)) + core.RunInteractively(); } - /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> - private void PressAnyKeyToExit() + /// <summary>Write an error directly to the console and exit.</summary> + /// <param name="message">The error message to display.</param> + private static void PrintErrorAndExit(string message) { - this.Monitor.Log("Game has ended. Press any key to exit.", LogLevel.Info); - Program.PressAnyKeyToExit(showMessage: false); + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine(message); + Console.ResetColor(); + Program.PressAnyKeyToExit(showMessage: true); } /// <summary>Show a 'press any key to exit' message, and exit when they press a key.</summary> @@ -1278,70 +147,5 @@ namespace StardewModdingAPI 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, this.Settings.ColorScheme) - { - WriteToConsole = this.Monitor.WriteToConsole, - ShowTraceInConsole = this.Settings.DeveloperMode, - ShowFullStampInConsole = this.Settings.DeveloperMode - }; - } - - /// <summary>Log a message if verbose mode is enabled.</summary> - /// <param name="message">The message to log.</param> - private void VerboseLog(string message) - { - if (this.Settings.VerboseLogging) - this.Monitor.Log(message, LogLevel.Trace); - } - - /// <summary>Get the absolute path to the next available log file.</summary> - private string GetLogPath() - { - // default path - { - FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); - if (!defaultFile.Exists) - return defaultFile.FullName; - } - - // get first disambiguated path - for (int i = 2; i < int.MaxValue; i++) - { - FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); - if (!file.Exists) - return file.FullName; - } - - // should never happen - throw new InvalidOperationException("Could not find an available log path."); - } - - /// <summary>Delete all log files created by SMAPI.</summary> - private void PurgeLogFiles() - { - DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); - if (!logsDir.Exists) - return; - - foreach (FileInfo logFile in logsDir.EnumerateFiles()) - { - if (logFile.Name.StartsWith(Constants.LogNamePrefix, StringComparison.InvariantCultureIgnoreCase)) - { - try - { - FileUtilities.ForceDelete(logFile); - } - catch (IOException) - { - // ignore file if it's in use - } - } - } - } } } diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index 587ff286..401f62c2 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -1,5 +1,6 @@ using System; using Newtonsoft.Json; +using StardewModdingAPI.Framework; namespace StardewModdingAPI { @@ -12,6 +13,9 @@ namespace StardewModdingAPI /// <summary>The underlying semantic version implementation.</summary> private readonly ISemanticVersion Version; + /// <summary>Manages deprecation warnings.</summary> + internal static DeprecationManager DeprecationManager { get; set; } + /********* ** Accessors @@ -26,7 +30,18 @@ namespace StardewModdingAPI public int PatchVersion => this.Version.PatchVersion; /// <summary>An optional build tag.</summary> - public string Build => this.Version.Build; + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] + public string Build + { + get + { + SemanticVersion.DeprecationManager?.Warn($"{nameof(ISemanticVersion)}.{nameof(ISemanticVersion.Build)}", "2.8", DeprecationLevel.Notice); + return this.Version.PrereleaseTag; + } + } + + /// <summary>An optional prerelease tag.</summary> + public string PrereleaseTag => this.Version.PrereleaseTag; /********* @@ -70,7 +85,7 @@ namespace StardewModdingAPI /// <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> + /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks> public int CompareTo(ISemanticVersion other) { return this.Version.CompareTo(other); diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/StardewModdingAPI.config.json index 115997ba..ad908fc0 100644 --- a/src/SMAPI/StardewModdingAPI.config.json +++ b/src/SMAPI/StardewModdingAPI.config.json @@ -9,10 +9,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha */ { /** - * Whether to enable features intended for mod developers. Currently this only makes TRACE-level - * messages appear in the console. + * The console color theme to use. The possible values are: + * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. + * - LightBackground: use darker text colors that look better on a white or light background. + * - DarkBackground: use lighter text colors that look better on a black or dark background. */ - "DeveloperMode": true, + "ColorScheme": "AutoDetect", /** * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new @@ -22,8 +24,23 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "CheckForUpdates": true, /** - * Whether SMAPI should show newer beta versions as an available update. If not specified, SMAPI - * will only show beta updates if the current version is beta. + * Whether to enable features intended for mod developers. Currently this only makes TRACE-level + * messages appear in the console. + */ + "DeveloperMode": true, + + /** + * Whether to add a section to the 'mod issues' list for mods which directly use potentially + * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as + * part of their normal functionality, so these warnings are meaningless without further + * investigation. When this is commented out, it'll be true for local debug builds and false + * otherwise. + */ + //"ParanoidWarnings": true, + + /** + * Whether SMAPI should show newer beta versions as an available update. When this is commented + * out, it'll be true if the current SMAPI version is beta, and false otherwise. */ //"UseBetaChannel": true, @@ -51,14 +68,6 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "DumpMetadata": false, /** - * The console color theme to use. The possible values are: - * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. - * - LightBackground: use darker text colors that look better on a white or light background. - * - DarkBackground: use lighter text colors that look better on a black or dark background. - */ - "ColorScheme": "AutoDetect", - - /** * The mod IDs SMAPI should ignore when performing update checks or validating update keys. */ "SuppressUpdateChecks": [ diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index fc2d45ba..5a098b8a 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="12.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> +<Project ToolsVersion="15.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> @@ -54,26 +54,12 @@ <ApplicationIcon>icon.ico</ApplicationIcon> </PropertyGroup> <ItemGroup> - <Reference Include="0Harmony, Version=1.0.9.1, Culture=neutral, processorArchitecture=MSIL"> - <SpecificVersion>False</SpecificVersion> - <HintPath>..\lib\0Harmony.dll</HintPath> - </Reference> - <Reference Include="Mono.Cecil, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Mono.Cecil.Mdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Mdb.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Mono.Cecil.Pdb, Version=0.10.0.0, Culture=neutral, PublicKeyToken=50cebf1cceb9d05e, processorArchitecture=MSIL"> - <HintPath>..\packages\Mono.Cecil.0.10.0\lib\net40\Mono.Cecil.Pdb.dll</HintPath> - <Private>True</Private> - </Reference> - <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> - <Private>True</Private> - </Reference> + <PackageReference Include="LargeAddressAware" Version="1.0.3" /> + <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> + <PackageReference Include="Mono.Cecil" Version="0.10.1" /> + <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> + </ItemGroup> + <ItemGroup> <Reference Include="System" /> <Reference Include="System.Core" /> <Reference Include="System.Drawing" /> @@ -90,25 +76,116 @@ <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> - <Compile Include="Events\GameLoopUpdatedEventArgs.cs" /> - <Compile Include="Events\GameLoopLaunchedEventArgs.cs" /> - <Compile Include="Events\InputMouseWheelScrolledEventArgs.cs" /> - <Compile Include="Events\InputCursorMovedEventArgs.cs" /> - <Compile Include="Events\InputButtonReleasedEventArgs.cs" /> - <Compile Include="Events\InputButtonPressedEventArgs.cs" /> + <Compile Include="Enums\SkillType.cs" /> + <Compile Include="Events\BuildingListChangedEventArgs.cs" /> + <Compile Include="Events\ButtonPressedEventArgs.cs" /> + <Compile Include="Events\ButtonReleasedEventArgs.cs" /> + <Compile Include="Events\ChangeType.cs" /> + <Compile Include="Events\ContentEvents.cs" /> + <Compile Include="Events\ControlEvents.cs" /> + <Compile Include="Events\CursorMovedEventArgs.cs" /> + <Compile Include="Events\DayEndingEventArgs.cs" /> + <Compile Include="Events\DayStartedEventArgs.cs" /> + <Compile Include="Events\DebrisListChangedEventArgs.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\EventArgsInput.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\EventArgsLocationBuildingsChanged.cs" /> - <Compile Include="Events\IInputEvents.cs" /> + <Compile Include="Events\EventArgsLocationObjectsChanged.cs" /> + <Compile Include="Events\EventArgsLocationsChanged.cs" /> + <Compile Include="Events\EventArgsMineLevelChanged.cs" /> + <Compile Include="Events\EventArgsMouseStateChanged.cs" /> + <Compile Include="Events\EventArgsPlayerWarped.cs" /> + <Compile Include="Events\EventArgsValueChanged.cs" /> + <Compile Include="Events\GameEvents.cs" /> + <Compile Include="Events\GameLaunchedEventArgs.cs" /> + <Compile Include="Events\GraphicsEvents.cs" /> + <Compile Include="Events\IDisplayEvents.cs" /> <Compile Include="Events\IGameLoopEvents.cs" /> + <Compile Include="Events\IInputEvents.cs" /> + <Compile Include="Events\IModEvents.cs" /> + <Compile Include="Events\IMultiplayerEvents.cs" /> + <Compile Include="Events\InputEvents.cs" /> + <Compile Include="Events\InventoryChangedEventArgs.cs" /> + <Compile Include="Events\IPlayerEvents.cs" /> + <Compile Include="Events\ISpecialisedEvents.cs" /> + <Compile Include="Events\ItemStackChange.cs" /> + <Compile Include="Events\ItemStackSizeChange.cs" /> <Compile Include="Events\IWorldEvents.cs" /> + <Compile Include="Events\LargeTerrainFeatureListChangedEventArgs.cs" /> + <Compile Include="Events\LevelChangedEventArgs.cs" /> + <Compile Include="Events\LocationEvents.cs" /> + <Compile Include="Events\LocationListChangedEventArgs.cs" /> + <Compile Include="Events\MenuChangedEventArgs.cs" /> + <Compile Include="Events\MenuEvents.cs" /> + <Compile Include="Events\MineEvents.cs" /> + <Compile Include="Events\ModMessageReceivedEventArgs.cs" /> + <Compile Include="Events\MouseWheelScrolledEventArgs.cs" /> <Compile Include="Events\MultiplayerEvents.cs" /> - <Compile Include="Events\WorldDebrisListChangedEventArgs.cs" /> - <Compile Include="Events\GameLoopUpdatingEventArgs.cs" /> - <Compile Include="Events\WorldNpcListChangedEventArgs.cs" /> - <Compile Include="Events\WorldLargeTerrainFeatureListChangedEventArgs.cs" /> - <Compile Include="Events\WorldTerrainFeatureListChangedEventArgs.cs" /> - <Compile Include="Events\WorldBuildingListChangedEventArgs.cs" /> - <Compile Include="Events\WorldLocationListChangedEventArgs.cs" /> - <Compile Include="Events\WorldObjectListChangedEventArgs.cs" /> + <Compile Include="Events\NpcListChangedEventArgs.cs" /> + <Compile Include="Events\ObjectListChangedEventArgs.cs" /> + <Compile Include="Events\PeerContextReceivedEventArgs.cs" /> + <Compile Include="Events\PeerDisconnectedEventArgs.cs" /> + <Compile Include="Events\PlayerEvents.cs" /> + <Compile Include="Events\RenderedActiveMenuEventArgs.cs" /> + <Compile Include="Events\RenderedEventArgs.cs" /> + <Compile Include="Events\RenderedHudEventArgs.cs" /> + <Compile Include="Events\RenderedWorldEventArgs.cs" /> + <Compile Include="Events\RenderingActiveMenuEventArgs.cs" /> + <Compile Include="Events\RenderingEventArgs.cs" /> + <Compile Include="Events\RenderingHudEventArgs.cs" /> + <Compile Include="Events\RenderingWorldEventArgs.cs" /> + <Compile Include="Events\ReturnedToTitleEventArgs.cs" /> + <Compile Include="Events\SaveCreatedEventArgs.cs" /> + <Compile Include="Events\SaveCreatingEventArgs.cs" /> + <Compile Include="Events\SavedEventArgs.cs" /> + <Compile Include="Events\SaveEvents.cs" /> + <Compile Include="Events\SaveLoadedEventArgs.cs" /> + <Compile Include="Events\SavingEventArgs.cs" /> + <Compile Include="Events\SpecialisedEvents.cs" /> + <Compile Include="Events\TerrainFeatureListChangedEventArgs.cs" /> + <Compile Include="Events\TimeChangedEventArgs.cs" /> + <Compile Include="Events\TimeEvents.cs" /> + <Compile Include="Events\UnvalidatedUpdateTickedEventArgs.cs" /> + <Compile Include="Events\UnvalidatedUpdateTickingEventArgs.cs" /> + <Compile Include="Events\UpdateTickedEventArgs.cs" /> + <Compile Include="Events\UpdateTickingEventArgs.cs" /> + <Compile Include="Events\WarpedEventArgs.cs" /> + <Compile Include="Events\WindowResizedEventArgs.cs" /> + <Compile Include="Framework\DeprecationWarning.cs" /> + <Compile Include="Framework\Events\EventManager.cs" /> + <Compile Include="Framework\Events\ManagedEvent.cs" /> + <Compile Include="Framework\Events\ManagedEventBase.cs" /> + <Compile Include="Framework\Events\ModDisplayEvents.cs" /> + <Compile Include="Framework\Events\ModEvents.cs" /> + <Compile Include="Framework\Events\ModEventsBase.cs" /> + <Compile Include="Framework\Events\ModGameLoopEvents.cs" /> + <Compile Include="Framework\Events\ModInputEvents.cs" /> + <Compile Include="Framework\Events\ModMultiplayerEvents.cs" /> + <Compile Include="Framework\Events\ModPlayerEvents.cs" /> + <Compile Include="Framework\Events\ModSpecialisedEvents.cs" /> + <Compile Include="Framework\Events\ModWorldEvents.cs" /> + <Compile Include="Framework\ModHelpers\DataHelper.cs" /> + <Compile Include="Framework\Networking\MessageType.cs" /> + <Compile Include="Framework\Networking\ModMessageModel.cs" /> + <Compile Include="Framework\Networking\MultiplayerPeer.cs" /> + <Compile Include="Framework\Networking\MultiplayerPeerMod.cs" /> + <Compile Include="Framework\Networking\RemoteContextModel.cs" /> + <Compile Include="Framework\Networking\RemoteContextModModel.cs" /> + <Compile Include="Framework\Networking\SGalaxyNetClient.cs" /> + <Compile Include="Framework\Networking\SGalaxyNetServer.cs" /> + <Compile Include="Framework\Networking\SLidgrenClient.cs" /> + <Compile Include="Framework\Networking\SLidgrenServer.cs" /> + <Compile Include="Framework\SCore.cs" /> <Compile Include="Framework\SGameConstructorHack.cs" /> <Compile Include="Framework\ContentManagers\BaseContentManager.cs" /> <Compile Include="Framework\ContentManagers\GameContentManager.cs" /> @@ -121,21 +198,15 @@ <Compile Include="Framework\Serialisation\ColorConverter.cs" /> <Compile Include="Framework\Serialisation\PointConverter.cs" /> <Compile Include="Framework\Serialisation\RectangleConverter.cs" /> - <Compile Include="Framework\Events\ModEventsBase.cs" /> - <Compile Include="Framework\Events\EventManager.cs" /> - <Compile Include="Events\IModEvents.cs" /> - <Compile Include="Framework\Events\ManagedEvent.cs" /> - <Compile Include="Events\SpecialisedEvents.cs" /> <Compile Include="Framework\ContentPack.cs" /> <Compile Include="Framework\Content\ContentCache.cs" /> - <Compile Include="Framework\Events\ManagedEventBase.cs" /> - <Compile Include="Framework\Events\ModEvents.cs" /> - <Compile Include="Framework\Events\ModGameLoopEvents.cs" /> - <Compile Include="Framework\Events\ModInputEvents.cs" /> <Compile Include="Framework\Input\GamePadStateBuilder.cs" /> <Compile Include="Framework\ModHelpers\InputHelper.cs" /> + <Compile Include="Framework\SModHooks.cs" /> + <Compile Include="Framework\Singleton.cs" /> <Compile Include="Framework\StateTracking\Comparers\GenericEqualsComparer.cs" /> <Compile Include="Framework\WatcherCore.cs" /> + <Compile Include="IDataHelper.cs" /> <Compile Include="IInputHelper.cs" /> <Compile Include="Framework\Input\SInputState.cs" /> <Compile Include="Framework\Input\InputStatus.cs" /> @@ -156,12 +227,10 @@ <Compile Include="Framework\ModLoading\Rewriters\StaticFieldToConstantRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\FieldToPropertyRewriter.cs" /> <Compile Include="Framework\ModLoading\Finders\ReferenceToMemberWithUnexpectedTypeFinder.cs" /> - <Compile Include="Framework\ModLoading\Rewriters\VirtualEntryCallRemover.cs" /> <Compile Include="Framework\ModLoading\Rewriters\MethodParentRewriter.cs" /> <Compile Include="Framework\ModLoading\Rewriters\TypeReferenceRewriter.cs" /> <Compile Include="Framework\Exceptions\SAssemblyLoadFailedException.cs" /> <Compile Include="Framework\ModLoading\AssemblyLoadStatus.cs" /> - <Compile Include="Framework\Events\ModWorldEvents.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyBuilder.cs" /> <Compile Include="Framework\Reflection\InterfaceProxyFactory.cs" /> <Compile Include="Framework\RewriteFacades\SpriteBatchMethods.cs" /> @@ -184,39 +253,19 @@ <Compile Include="Framework\StateTracking\PlayerTracker.cs" /> <Compile Include="Framework\Utilities\ContextHash.cs" /> <Compile Include="IContentPack.cs" /> + <Compile Include="IModInfo.cs" /> <Compile Include="IMultiplayerHelper.cs" /> + <Compile Include="IMultiplayerPeer.cs" /> <Compile Include="IReflectedField.cs" /> <Compile Include="IReflectedMethod.cs" /> <Compile Include="IReflectedProperty.cs" /> + <Compile Include="IMultiplayerPeerMod.cs" /> <Compile Include="Metadata\CoreAssetPropagator.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="Constants.cs" /> - <Compile Include="Events\ControlEvents.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\EventArgsPlayerWarped.cs" /> - <Compile Include="Events\EventArgsLocationsChanged.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\EventArgsLocationObjectsChanged.cs" /> - <Compile Include="Events\EventArgsMineLevelChanged.cs" /> - <Compile Include="Events\EventArgsMouseStateChanged.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" /> @@ -255,12 +304,6 @@ <Compile Include="IAssetDataForImage.cs" /> <Compile Include="IContentHelper.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" /> @@ -277,11 +320,10 @@ <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> <Compile Include="IMonitor.cs" /> - <Compile Include="Events\ChangeType.cs" /> - <Compile Include="Events\ItemStackChange.cs" /> <Compile Include="Framework\Monitor.cs" /> <Compile Include="Metadata\InstructionMetadata.cs" /> <Compile Include="Mod.cs" /> + <Compile Include="Patches\DialogueErrorPatch.cs" /> <Compile Include="PatchMode.cs" /> <Compile Include="GamePlatform.cs" /> <Compile Include="Program.cs" /> @@ -296,9 +338,6 @@ <Compile Include="Framework\CursorPosition.cs" /> </ItemGroup> <ItemGroup> - <None Include="packages.config"> - <SubType>Designer</SubType> - </None> <Content Include="StardewModdingAPI.config.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> @@ -341,11 +380,4 @@ <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> <Import Project="..\..\build\common.targets" /> - <Import Project="..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets" Condition="Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" /> - <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild"> - <PropertyGroup> - <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText> - </PropertyGroup> - <Error Condition="!Exists('..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\LargeAddressAware.1.0.3\build\LargeAddressAware.targets'))" /> - </Target> </Project>
\ No newline at end of file diff --git a/src/SMAPI/packages.config b/src/SMAPI/packages.config deleted file mode 100644 index 84c6bed0..00000000 --- a/src/SMAPI/packages.config +++ /dev/null @@ -1,6 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<packages> - <package id="LargeAddressAware" version="1.0.3" targetFramework="net45" /> - <package id="Mono.Cecil" version="0.10.0" targetFramework="net45" /> - <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> -</packages>
\ No newline at end of file diff --git a/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index 961ef777..6631b01d 100644 --- a/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/StardewModdingAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -18,8 +18,12 @@ namespace StardewModdingAPI int PatchVersion { get; } /// <summary>An optional build tag.</summary> + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] string Build { get; } + /// <summary>An optional prerelease tag.</summary> + string PrereleaseTag { get; } + /********* ** Accessors diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 2aafe199..8a9c0a25 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -18,9 +18,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> public ModEntryVersionModel Unofficial { get; set; } + /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> + public ModEntryVersionModel UnofficialForBeta { get; set; } + /// <summary>Optional extended data which isn't needed for update checks.</summary> public ModExtendedMetadataModel Metadata { get; set; } + /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary> + public bool HasBetaInfo { get; set; } + /// <summary>The errors that occurred while fetching update data.</summary> public string[] Errors { get; set; } = new string[0]; } diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 21376b36..247730d7 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -13,6 +13,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /********* ** Accessors *********/ + /**** + ** Mod info + ****/ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> public string[] ID { get; set; } = new string[0]; @@ -34,6 +37,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The custom mod page URL (if applicable).</summary> public string CustomUrl { get; set; } + /**** + ** Stable compatibility + ****/ /// <summary>The compatibility status.</summary> [JsonConverter(typeof(StringEnumConverter))] public WikiCompatibilityStatus? CompatibilityStatus { get; set; } @@ -42,6 +48,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public string CompatibilitySummary { get; set; } + /**** + ** Beta compatibility + ****/ + /// <summary>The compatibility status for the Stardew Valley beta (if any).</summary> + [JsonConverter(typeof(StringEnumConverter))] + public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatitng.</summary> + public string BetaCompatibilitySummary { get; set; } + + /********* ** Public methods *********/ @@ -51,20 +68,24 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Construct an instance.</summary> /// <param name="wiki">The mod metadata from the wiki (if available).</param> /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param> - public ModExtendedMetadataModel(WikiCompatibilityEntry wiki, ModDataRecord db) + public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db) { // wiki data if (wiki != null) { this.ID = wiki.ID; - this.Name = wiki.Name; + this.Name = wiki.Name.FirstOrDefault(); this.NexusID = wiki.NexusID; this.ChucklefishID = wiki.ChucklefishID; this.GitHubRepo = wiki.GitHubRepo; this.CustomSourceUrl = wiki.CustomSourceUrl; this.CustomUrl = wiki.CustomUrl; - this.CompatibilityStatus = wiki.Status; - this.CompatibilitySummary = wiki.Summary; + + this.CompatibilityStatus = wiki.Compatibility.Status; + this.CompatibilitySummary = wiki.Compatibility.Summary; + + this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status; + this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary; } // internal DB data diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs new file mode 100644 index 00000000..7197bf2c --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.Http.Client; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>An HTTP client for fetching mod metadata from the wiki.</summary> + public class WikiClient : IDisposable + { + /********* + ** Properties + *********/ + /// <summary>The underlying HTTP client.</summary> + private readonly IClient Client; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="userAgent">The user agent for the wiki API.</param> + /// <param name="baseUrl">The base URL for the wiki API.</param> + public WikiClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") + { + this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); + } + + /// <summary>Fetch mods from the compatibility list.</summary> + public async Task<WikiModList> FetchModsAsync() + { + // fetch HTML + ResponseModel response = await this.Client + .GetAsync("") + .WithArguments(new + { + action = "parse", + page = "Modding:SMAPI_compatibility", + format = "json" + }) + .As<ResponseModel>(); + string html = response.Parse.Text["*"]; + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // fetch game versions + string stableVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-stable-version']")?.InnerText; + string betaVersion = doc.DocumentNode.SelectSingleNode("div[@class='game-beta-version']")?.InnerText; + + // find mod entries + HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); + if (modNodes == null) + throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); + + // parse + WikiModEntry[] mods = this.ParseEntries(modNodes).ToArray(); + return new WikiModList + { + StableVersion = stableVersion, + BetaVersion = betaVersion, + Mods = mods + }; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Parse valid mod compatibility entries.</summary> + /// <param name="nodes">The HTML compatibility entries.</param> + private IEnumerable<WikiModEntry> ParseEntries(IEnumerable<HtmlNode> nodes) + { + foreach (HtmlNode node in nodes) + { + // extract fields + string[] names = this.GetAttributeAsCsv(node, "data-name"); + string[] authors = this.GetAttributeAsCsv(node, "data-author"); + string[] ids = this.GetAttributeAsCsv(node, "data-id"); + string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); + int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); + int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); + string githubRepo = this.GetAttribute(node, "data-github"); + string customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string customUrl = this.GetAttribute(node, "data-url"); + string anchor = this.GetAttribute(node, "id"); + + // parse stable compatibility + WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo + { + Status = this.GetAttributeAsStatus(node, "data-status") ?? WikiCompatibilityStatus.Ok, + BrokeIn = this.GetAttribute(node, "data-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() + }; + + // parse beta compatibility + WikiCompatibilityInfo betaCompatibility = null; + { + WikiCompatibilityStatus? betaStatus = this.GetAttributeAsStatus(node, "data-beta-status"); + if (betaStatus.HasValue) + { + betaCompatibility = new WikiCompatibilityInfo + { + Status = betaStatus.Value, + BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), + UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), + Summary = this.GetInnerHtml(node, "mod-beta-summary") + }; + } + } + + // yield model + yield return new WikiModEntry + { + ID = ids, + Name = names, + Author = authors, + NexusID = nexusID, + ChucklefishID = chucklefishID, + GitHubRepo = githubRepo, + CustomSourceUrl = customSourceUrl, + CustomUrl = customUrl, + Compatibility = compatibility, + BetaCompatibility = betaCompatibility, + Warnings = warnings, + Anchor = anchor + }; + } + } + + /// <summary>Get an attribute value.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private string GetAttribute(HtmlNode element, string name) + { + string value = element.GetAttributeValue(name, null); + if (string.IsNullOrWhiteSpace(value)) + return null; + + return WebUtility.HtmlDecode(value); + } + + /// <summary>Get an attribute value and parse it as a comma-delimited list of strings.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private string[] GetAttributeAsCsv(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return !string.IsNullOrWhiteSpace(raw) + ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() + : new string[0]; + } + + /// <summary>Get an attribute value and parse it as a compatibility status.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private WikiCompatibilityStatus? GetAttributeAsStatus(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + if (raw == null) + return null; + if (!Enum.TryParse(raw, true, out WikiCompatibilityStatus status)) + throw new InvalidOperationException($"Unknown status '{raw}' when parsing compatibility list."); + return status; + } + + /// <summary>Get an attribute value and parse it as a semantic version.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion version) + ? version + : null; + } + + /// <summary>Get an attribute value and parse it as a nullable int.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private int? GetAttributeAsNullableInt(HtmlNode element, string name) + { + string raw = this.GetAttribute(element, name); + if (raw != null && int.TryParse(raw, out int value)) + return value; + return null; + } + + /// <summary>Get the text of an element with the given class name.</summary> + /// <param name="container">The metadata container.</param> + /// <param name="className">The field name.</param> + private string GetInnerHtml(HtmlNode container, string className) + { + return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; + } + + /// <summary>The response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseModel + { + /// <summary>The parse API results.</summary> + public ResponseParseModel Parse { get; set; } + } + + /// <summary>The inner response model for the MediaWiki parse API.</summary> + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] + [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] + private class ResponseParseModel + { + /// <summary>The parsed text.</summary> + public IDictionary<string, string> Text { get; set; } + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs deleted file mode 100644 index d0da42df..00000000 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityClient.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; - -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki -{ - /// <summary>An HTTP client for fetching mod metadata from the wiki compatibility list.</summary> - public class WikiCompatibilityClient : IDisposable - { - /********* - ** Properties - *********/ - /// <summary>The underlying HTTP client.</summary> - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="userAgent">The user agent for the wiki API.</param> - /// <param name="baseUrl">The base URL for the wiki API.</param> - public WikiCompatibilityClient(string userAgent, string baseUrl = "https://stardewvalleywiki.com/mediawiki/api.php") - { - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// <summary>Fetch mod compatibility entries.</summary> - public async Task<WikiCompatibilityEntry[]> FetchAsync() - { - // fetch HTML - ResponseModel response = await this.Client - .GetAsync("") - .WithArguments(new - { - action = "parse", - page = "Modding:SMAPI_compatibility", - format = "json" - }) - .As<ResponseModel>(); - string html = response.Parse.Text["*"]; - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // find mod entries - HtmlNodeCollection modNodes = doc.DocumentNode.SelectNodes("table[@id='mod-list']//tr[@class='mod']"); - if (modNodes == null) - throw new InvalidOperationException("Can't parse wiki compatibility list, no mods found."); - - // parse - return this.ParseEntries(modNodes).ToArray(); - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - this.Client?.Dispose(); - } - - - /********* - ** Private methods - *********/ - /// <summary>Parse valid mod compatibility entries.</summary> - /// <param name="nodes">The HTML compatibility entries.</param> - private IEnumerable<WikiCompatibilityEntry> ParseEntries(IEnumerable<HtmlNode> nodes) - { - foreach (HtmlNode node in nodes) - { - // parse status - WikiCompatibilityStatus status; - { - string rawStatus = node.GetAttributeValue("data-status", null); - if (rawStatus == null) - continue; // not a mod node? - if (!Enum.TryParse(rawStatus, true, out status)) - throw new InvalidOperationException($"Unknown status '{rawStatus}' when parsing compatibility list."); - } - - // parse unofficial version - ISemanticVersion unofficialVersion = null; - { - string rawUnofficialVersion = node.GetAttributeValue("data-unofficial-version", null); - SemanticVersion.TryParse(rawUnofficialVersion, out unofficialVersion); - } - - // parse other fields - string name = node.Descendants("td").FirstOrDefault()?.InnerText?.Trim(); - string summary = node.Descendants("td").FirstOrDefault(p => p.GetAttributeValue("class", null) == "summary")?.InnerText.Trim(); - string[] ids = this.GetAttribute(node, "data-id")?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() ?? new string[0]; - int? nexusID = this.GetNullableIntAttribute(node, "data-nexus-id"); - int? chucklefishID = this.GetNullableIntAttribute(node, "data-chucklefish-id"); - string githubRepo = this.GetAttribute(node, "data-github"); - string customSourceUrl = this.GetAttribute(node, "data-custom-source"); - string customUrl = this.GetAttribute(node, "data-custom-url"); - - // yield model - yield return new WikiCompatibilityEntry - { - ID = ids, - Name = name, - Status = status, - NexusID = nexusID, - ChucklefishID = chucklefishID, - GitHubRepo = githubRepo, - CustomSourceUrl = customSourceUrl, - CustomUrl = customUrl, - UnofficialVersion = unofficialVersion, - Summary = summary - }; - } - } - - /// <summary>Get a nullable integer attribute value.</summary> - /// <param name="node">The HTML node.</param> - /// <param name="attributeName">The attribute name.</param> - private int? GetNullableIntAttribute(HtmlNode node, string attributeName) - { - string raw = this.GetAttribute(node, attributeName); - if (raw != null && int.TryParse(raw, out int value)) - return value; - return null; - } - - /// <summary>Get a strings attribute value.</summary> - /// <param name="node">The HTML node.</param> - /// <param name="attributeName">The attribute name.</param> - private string GetAttribute(HtmlNode node, string attributeName) - { - string raw = node.GetAttributeValue(attributeName, null); - if (raw != null) - raw = HtmlEntity.DeEntitize(raw); - return raw; - } - - /// <summary>The response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] - private class ResponseModel - { - /// <summary>The parse API results.</summary> - public ResponseParseModel Parse { get; set; } - } - - /// <summary>The inner response model for the MediaWiki parse API.</summary> - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] - [SuppressMessage("ReSharper", "CollectionNeverUpdated.Local")] - [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] - private class ResponseParseModel - { - /// <summary>The parsed text.</summary> - public IDictionary<string, string> Text { get; set; } - } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs deleted file mode 100644 index 8bc66e20..00000000 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityEntry.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki -{ - /// <summary>An entry in the mod compatibility list.</summary> - public class WikiCompatibilityEntry - { - /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> - public string[] ID { get; set; } - - /// <summary>The mod's display name.</summary> - public string Name { get; set; } - - /// <summary>The mod ID on Nexus.</summary> - public int? NexusID { get; set; } - - /// <summary>The mod ID in the Chucklefish mod repo.</summary> - public int? ChucklefishID { get; set; } - - /// <summary>The GitHub repository in the form 'owner/repo'.</summary> - public string GitHubRepo { get; set; } - - /// <summary>The URL to a non-GitHub source repo.</summary> - public string CustomSourceUrl { get; set; } - - /// <summary>The custom mod page URL (if applicable).</summary> - public string CustomUrl { get; set; } - - /// <summary>The version of the latest unofficial update, if applicable.</summary> - public ISemanticVersion UnofficialVersion { get; set; } - - /// <summary>The compatibility status.</summary> - public WikiCompatibilityStatus Status { get; set; } - - /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary> - public string Summary { get; set; } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs new file mode 100644 index 00000000..204acd2b --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>Compatibility info for a mod.</summary> + public class WikiCompatibilityInfo + { + /********* + ** Accessors + *********/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus Status { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string Summary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string BrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public ISemanticVersion UnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string UnofficialUrl { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs new file mode 100644 index 00000000..ce8d6c5f --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -0,0 +1,48 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>A mod entry in the wiki list.</summary> + public class WikiModEntry + { + /********* + ** Accessors + *********/ + /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> + public string[] ID { get; set; } + + /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> + public string[] Name { get; set; } + + /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> + public string[] Author { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The GitHub repository in the form 'owner/repo'.</summary> + public string GitHubRepo { get; set; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The mod's compatibility with the latest stable version of the game.</summary> + public WikiCompatibilityInfo Compatibility { get; set; } + + /// <summary>The mod's compatibility with the latest beta version of the game (if any).</summary> + public WikiCompatibilityInfo BetaCompatibility { get; set; } + + /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="BetaCompatibility"/> should be used for beta versions of SMAPI instead of <see cref="Compatibility"/>.</summary> + public bool HasBetaInfo => this.BetaCompatibility != null; + + /// <summary>The human-readable warnings for players about this mod.</summary> + public string[] Warnings { get; set; } + + /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> + public string Anchor { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs new file mode 100644 index 00000000..0d614f28 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki +{ + /// <summary>Metadata from the wiki's mod compatibility list.</summary> + public class WikiModList + { + /********* + ** Accessors + *********/ + /// <summary>The stable game version.</summary> + public string StableVersion { get; set; } + + /// <summary>The beta game version (if any).</summary> + public string BetaVersion { get; set; } + + /// <summary>The mods on the wiki.</summary> + public WikiModEntry[] Mods { get; set; } + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs index 82ac8837..3949f7dc 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -96,6 +96,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData .Distinct(); } + /// <summary>Get the default update key for this mod, if any.</summary> + public string GetDefaultUpdateKey() + { + string updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + return !string.IsNullOrWhiteSpace(updateKey) + ? updateKey + : null; + } + /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary> /// <param name="manifest">The manifest to match.</param> public ModDataRecordVersionedFields GetVersionedFields(IManifest manifest) diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs index 4aaa3f83..bb467b36 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { @@ -11,11 +12,11 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /********* ** Accessors *********/ - /// <summary>The Mods subfolder containing this mod.</summary> - public DirectoryInfo SearchDirectory { get; } + /// <summary>A suggested display name for the mod folder.</summary> + public string DisplayName { get; } - /// <summary>The folder containing manifest.json.</summary> - public DirectoryInfo ActualDirectory { get; } + /// <summary>The folder containing the mod's manifest.json.</summary> + public DirectoryInfo Directory { get; } /// <summary>The mod manifest.</summary> public Manifest Manifest { get; } @@ -23,21 +24,31 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The error which occurred parsing the manifest, if any.</summary> public string ManifestParseError { get; } + /// <summary>Whether the mod should be loaded by default. This is <c>false</c> if it was found within a folder whose name starts with a dot.</summary> + public bool ShouldBeLoaded { get; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="searchDirectory">The Mods subfolder containing this mod.</param> - /// <param name="actualDirectory">The folder containing manifest.json.</param> + /// <param name="root">The root folder containing mods.</param> + /// <param name="directory">The folder containing the mod's manifest.json.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param> - public ModFolder(DirectoryInfo searchDirectory, DirectoryInfo actualDirectory, Manifest manifest, string manifestParseError = null) + /// <param name="shouldBeLoaded">Whether the mod should be loaded by default. This should be <c>false</c> if it was found within a folder whose name starts with a dot.</param> + public ModFolder(DirectoryInfo root, DirectoryInfo directory, Manifest manifest, string manifestParseError = null, bool shouldBeLoaded = true) { - this.SearchDirectory = searchDirectory; - this.ActualDirectory = actualDirectory; + // save info + this.Directory = directory; this.Manifest = manifest; this.ManifestParseError = manifestParseError; + this.ShouldBeLoaded = shouldBeLoaded; + + // set display name + this.DisplayName = manifest?.Name; + if (string.IsNullOrWhiteSpace(this.DisplayName)) + this.DisplayName = PathUtilities.GetRelativePath(root.FullName, directory.FullName); } /// <summary>Get the update keys for a mod.</summary> diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs index f1cce4a4..106c294f 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -16,6 +16,23 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The JSON helper with which to read manifests.</summary> private readonly JsonHelper JsonHelper; + /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary> + private readonly HashSet<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + ".DS_Store", + "mcs", + "Thumbs.db" + }; + + /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary> + private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + ".md", + ".png", + ".txt", + ".xnb" + }; + /********* ** Public methods @@ -31,19 +48,28 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <param name="rootPath">The root folder containing mods.</param> public IEnumerable<ModFolder> GetModFolders(string rootPath) { - foreach (DirectoryInfo folder in new DirectoryInfo(rootPath).EnumerateDirectories()) - yield return this.ReadFolder(rootPath, folder); + DirectoryInfo root = new DirectoryInfo(rootPath); + return this.GetModFolders(root, root); } /// <summary>Extract information from a mod folder.</summary> - /// <param name="rootPath">The root folder containing mods.</param> + /// <param name="root">The root folder containing mods.</param> /// <param name="searchFolder">The folder to search for a mod.</param> - public ModFolder ReadFolder(string rootPath, DirectoryInfo searchFolder) + public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder) { // find manifest.json FileInfo manifestFile = this.FindManifest(searchFolder); + + // set appropriate invalid-mod error if (manifestFile == null) - return new ModFolder(searchFolder, null, null, "it doesn't have a manifest."); + { + FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray(); + if (!files.Any()) + return new ModFolder(root, searchFolder, null, "it's an empty folder."); + if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension))) + return new ModFolder(root, searchFolder, null, "it's an older XNB mod which replaces game files (not run through SMAPI)."); + return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json."); + } // read mod info Manifest manifest = null; @@ -51,7 +77,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning { try { - if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest)) + if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) manifestError = "its manifest is invalid."; } catch (SParseException ex) @@ -64,13 +90,37 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning } } - return new ModFolder(searchFolder, manifestFile.Directory, manifest, manifestError); + return new ModFolder(root, manifestFile.Directory, manifest, manifestError); } /********* ** Private methods *********/ + /// <summary>Recursively extract information about all mods in the given folder.</summary> + /// <param name="root">The root mod folder.</param> + /// <param name="folder">The folder to search for mods.</param> + public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) + { + // skip + if (folder.FullName != root.FullName && folder.Name.StartsWith(".")) + yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false); + + // recurse into subfolders + else if (this.IsModSearchFolder(root, folder)) + { + foreach (DirectoryInfo subfolder in folder.EnumerateDirectories()) + { + foreach (ModFolder match in this.GetModFolders(root, subfolder)) + yield return match; + } + } + + // treat as mod folder + else + yield return this.ReadFolder(root, folder); + } + /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> private FileInfo FindManifest(DirectoryInfo folder) @@ -94,5 +144,25 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return null; } } + + /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary> + /// <param name="root">The root mod folder.</param> + /// <param name="folder">The folder to search for mods.</param> + private bool IsModSearchFolder(DirectoryInfo root, DirectoryInfo folder) + { + if (root.FullName == folder.FullName) + return true; + + DirectoryInfo[] subfolders = folder.GetDirectories().Where(this.IsRelevant).ToArray(); + FileInfo[] files = folder.GetFiles().Where(this.IsRelevant).ToArray(); + return subfolders.Any() && !files.Any(); + } + + /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary> + /// <param name="entry">The file or folder.</param> + private bool IsRelevant(FileSystemInfo entry) + { + return !this.IgnoreFilesystemEntries.Contains(entry.Name); + } } } diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs new file mode 100644 index 00000000..7ca32f04 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// <summary>A mod repository which SMAPI can check for updates.</summary> + public enum ModRepositoryKey + { + /// <summary>An unknown or invalid mod repository.</summary> + Unknown, + + /// <summary>The Chucklefish mod repository.</summary> + Chucklefish, + + /// <summary>A GitHub project containing releases.</summary> + GitHub, + + /// <summary>The Nexus Mods mod repository.</summary> + Nexus + } +} diff --git a/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs new file mode 100644 index 00000000..865ebcf7 --- /dev/null +++ b/src/StardewModdingAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -0,0 +1,73 @@ +using System; + +namespace StardewModdingAPI.Toolkit.Framework.UpdateData +{ + /// <summary>A namespaced mod ID which uniquely identifies a mod within a mod repository.</summary> + public class UpdateKey + { + /********* + ** Accessors + *********/ + /// <summary>The raw update key text.</summary> + public string RawText { get; } + + /// <summary>The mod repository containing the mod.</summary> + public ModRepositoryKey Repository { get; } + + /// <summary>The mod ID within the repository.</summary> + public string ID { get; } + + /// <summary>Whether the update key seems to be valid.</summary> + public bool LooksValid { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rawText">The raw update key text.</param> + /// <param name="repository">The mod repository containing the mod.</param> + /// <param name="id">The mod ID within the repository.</param> + public UpdateKey(string rawText, ModRepositoryKey repository, string id) + { + this.RawText = rawText; + this.Repository = repository; + this.ID = id; + this.LooksValid = + repository != ModRepositoryKey.Unknown + && !string.IsNullOrWhiteSpace(id); + } + + /// <summary>Parse a raw update key.</summary> + /// <param name="raw">The raw update key to parse.</param> + public static UpdateKey Parse(string raw) + { + // split parts + string[] parts = raw?.Split(':'); + if (parts == null || parts.Length != 2) + return new UpdateKey(raw, ModRepositoryKey.Unknown, null); + + // extract parts + string repositoryKey = parts[0].Trim(); + string id = parts[1].Trim(); + if (string.IsNullOrWhiteSpace(id)) + id = null; + + // parse + if (!Enum.TryParse(repositoryKey, true, out ModRepositoryKey repository)) + return new UpdateKey(raw, ModRepositoryKey.Unknown, id); + if (id == null) + return new UpdateKey(raw, repository, null); + + return new UpdateKey(raw, repository, id); + } + + /// <summary>Get a string that represents the current object.</summary> + public override string ToString() + { + return this.LooksValid + ? $"{this.Repository}:{this.ID}" + : this.RawText; + } + } +} diff --git a/src/StardewModdingAPI.Toolkit/ModToolkit.cs b/src/StardewModdingAPI.Toolkit/ModToolkit.cs index 8c78b2f3..c55f6c70 100644 --- a/src/StardewModdingAPI.Toolkit/ModToolkit.cs +++ b/src/StardewModdingAPI.Toolkit/ModToolkit.cs @@ -47,10 +47,10 @@ namespace StardewModdingAPI.Toolkit } /// <summary>Extract mod metadata from the wiki compatibility list.</summary> - public async Task<WikiCompatibilityEntry[]> GetWikiCompatibilityListAsync() + public async Task<WikiModList> GetWikiCompatibilityListAsync() { - var client = new WikiCompatibilityClient(this.UserAgent); - return await client.FetchAsync(); + var client = new WikiClient(this.UserAgent); + return await client.FetchModsAsync(); } /// <summary>Get SMAPI's internal mod database.</summary> diff --git a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs index 156d58ce..a7990d13 100644 --- a/src/StardewModdingAPI.Toolkit/SemanticVersion.cs +++ b/src/StardewModdingAPI.Toolkit/SemanticVersion.cs @@ -4,7 +4,13 @@ using System.Text.RegularExpressions; namespace StardewModdingAPI.Toolkit { /// <summary>A semantic version with an optional release tag.</summary> - /// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks> + /// <remarks> + /// The implementation is defined by Semantic Version 2.0 (https://semver.org/), with a few deviations: + /// - short-form "x.y" versions are supported (equivalent to "x.y.0"); + /// - hyphens are synonymous with dots in prerelease tags (like "-unofficial.3-pathoschild"); + /// - +build suffixes are not supported; + /// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial"). + /// </remarks> public class SemanticVersion : ISemanticVersion { /********* @@ -17,13 +23,7 @@ namespace StardewModdingAPI.Toolkit internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>" + SemanticVersion.TagPattern + "))?"; /// <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> + /// <remarks>This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>, with deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks> internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture); @@ -40,7 +40,14 @@ namespace StardewModdingAPI.Toolkit public int PatchVersion { get; } /// <summary>An optional prerelease tag.</summary> - public string Build { get; } + [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] + public string Build => this.PrereleaseTag; + + /// <summary>An optional prerelease tag.</summary> + public string PrereleaseTag { get; } + + /// <summary>Whether the version was parsed from the legacy object format.</summary> + public bool IsLegacyFormat { get; } /********* @@ -51,12 +58,14 @@ namespace StardewModdingAPI.Toolkit /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> /// <param name="tag">An optional prerelease tag.</param> - public SemanticVersion(int major, int minor, int patch, string tag = null) + /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param> + public SemanticVersion(int major, int minor, int patch, string tag = null, bool isLegacyFormat = false) { this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; - this.Build = this.GetNormalisedTag(tag); + this.PrereleaseTag = this.GetNormalisedTag(tag); + this.IsLegacyFormat = isLegacyFormat; this.AssertValid(); } @@ -93,7 +102,7 @@ namespace StardewModdingAPI.Toolkit 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; + this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; this.AssertValid(); } @@ -255,6 +264,12 @@ namespace StardewModdingAPI.Toolkit // compare if different if (curParts[i] != otherParts[i]) { + // unofficial is always lower-precedence + if (otherParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase)) + return curNewer; + if (curParts[i].Equals("unofficial", StringComparison.InvariantCultureIgnoreCase)) + return curOlder; + // compare numerically if possible { if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum)) diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs index 9b2f5e7d..e0e185c9 100644 --- a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs +++ b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs @@ -70,7 +70,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters if (build == "0") build = null; // '0' from incorrect examples in old SMAPI documentation - return new SemanticVersion(major, minor, patch, build); + return new SemanticVersion(major, minor, patch, build, isLegacyFormat: true); } /// <summary>Read a JSON string.</summary> @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters 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 (path: {path})."); - return (SemanticVersion)version; + return version; } } } diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs index cc8eeb73..cf2ce0d1 100644 --- a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs +++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs @@ -95,18 +95,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation Directory.CreateDirectory(dir); // write file - string json = JsonConvert.SerializeObject(model, this.JsonSettings); + string json = this.Serialise(model); File.WriteAllText(fullPath, json); } - - /********* - ** Private methods - *********/ /// <summary>Deserialize JSON text if possible.</summary> /// <typeparam name="TModel">The model type.</typeparam> /// <param name="json">The raw JSON text.</param> - private TModel Deserialise<TModel>(string json) + public TModel Deserialise<TModel>(string json) { try { @@ -127,5 +123,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation throw; } } + + /// <summary>Serialize a model to JSON text.</summary> + /// <typeparam name="TModel">The model type.</typeparam> + /// <param name="model">The model to serialise.</param> + /// <param name="formatting">The formatting to apply.</param> + public string Serialise<TModel>(TModel model, Formatting formatting = Formatting.Indented) + { + return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); + } } } diff --git a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj index 21c130b3..3fa28d19 100644 --- a/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj +++ b/src/StardewModdingAPI.Toolkit/StardewModdingAPI.Toolkit.csproj @@ -12,7 +12,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.4" /> + <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> </ItemGroup> diff --git a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs index 2e74e7d9..79748c25 100644 --- a/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/StardewModdingAPI.Toolkit/Utilities/PathUtilities.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.Contracts; using System.IO; using System.Linq; +using System.Text.RegularExpressions; namespace StardewModdingAPI.Toolkit.Utilities { @@ -61,5 +62,24 @@ namespace StardewModdingAPI.Toolkit.Utilities relative = "./"; return relative; } + + /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> + /// <param name="path">The path to check.</param> + public static bool IsSafeRelativePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return true; + + return + !Path.IsPathRooted(path) + && PathUtilities.GetSegments(path).All(segment => segment.Trim() != ".."); + } + + /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> + /// <param name="str">The string to check.</param> + public static bool IsSlug(string str) + { + return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + } } } diff --git a/src/lib/0Harmony.dll b/src/lib/0Harmony.dll Binary files differdeleted file mode 100644 index 63619429..00000000 --- a/src/lib/0Harmony.dll +++ /dev/null diff --git a/src/lib/0Harmony.pdb b/src/lib/0Harmony.pdb Binary files differdeleted file mode 100644 index d7a4c67c..00000000 --- a/src/lib/0Harmony.pdb +++ /dev/null |