diff options
author | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-14 12:21:40 -0400 |
---|---|---|
committer | Jesse Plamondon-Willard <github@jplamondonw.com> | 2018-08-14 12:21:40 -0400 |
commit | 4f28ea33bd7cc65485402c5e85259083e86b49e1 (patch) | |
tree | 86c4d8f9272de9a715cfcbf4008f0c09f5a59a21 /src | |
parent | 60b41195778af33fd609eab66d9ae3f1d1165e8f (diff) | |
parent | 4dd4efc96fac6a7ab66c14edead10e4fa988040d (diff) | |
download | SMAPI-4f28ea33bd7cc65485402c5e85259083e86b49e1.tar.gz SMAPI-4f28ea33bd7cc65485402c5e85259083e86b49e1.tar.bz2 SMAPI-4f28ea33bd7cc65485402c5e85259083e86b49e1.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src')
27 files changed, 722 insertions, 433 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs new file mode 100644 index 00000000..d212876a --- /dev/null +++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs @@ -0,0 +1,61 @@ +using System.IO; + +namespace StardewModdingAPI.Installer.Framework +{ + /// <summary>Manages paths for the SMAPI installer.</summary> + internal class InstallerPaths + { + /********* + ** Accessors + *********/ + /// <summary>The directory containing the installer files for the current platform.</summary> + public DirectoryInfo PackageDir { get; } + + /// <summary>The directory containing the installed game.</summary> + public DirectoryInfo GameDir { get; } + + /// <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 the directory containing the installed game.</summary> + public string GamePath => this.GameDir.FullName; + + /// <summary>The full path to the directory into which to install mods.</summary> + public string ModsPath => this.ModsDir.FullName; + + /// <summary>The full path to the installed SMAPI executable file.</summary> + public string ExecutablePath { get; } + + /// <summary>The full path to the vanilla game launcher on Linux/Mac.</summary> + public string UnixLauncherPath { get; } + + /// <summary>The full path to the installed SMAPI launcher on Linux/Mac before it's renamed.</summary> + public string UnixSmapiLauncherPath { get; } + + /// <summary>The full path to the vanilla game launcher on Linux/Mac after SMAPI is installed.</summary> + public string UnixBackupLauncherPath { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="packageDir">The directory path containing the installer files for the current platform.</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) + { + this.PackageDir = packageDir; + this.GameDir = gameDir; + this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods")); + + this.ExecutablePath = Path.Combine(gameDir.FullName, gameExecutableName); + this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley"); + this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); + this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original"); + } + } +} diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index f39486e1..0aac1da2 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -7,6 +7,7 @@ using System.Reflection; using System.Threading; using Microsoft.Win32; using StardewModdingApi.Installer.Enums; +using StardewModdingAPI.Installer.Framework; using StardewModdingAPI.Internal; using StardewModdingAPI.Internal.ConsoleWriting; @@ -135,7 +136,7 @@ namespace StardewModdingApi.Installer } /// <summary>Handles writing color-coded text to the console.</summary> - private readonly ColorfulConsoleWriter ConsoleWriter; + private ColorfulConsoleWriter ConsoleWriter; /********* @@ -168,6 +169,9 @@ namespace StardewModdingApi.Installer /// </remarks> public void Run(string[] args) { + /********* + ** Step 1: initial setup + *********/ /**** ** Get platform & set window title ****/ @@ -175,6 +179,9 @@ namespace StardewModdingApi.Installer Console.Title = $"SMAPI {this.GetDisplayVersion(this.GetType().Assembly.GetName().Version)} installer on {platform} {EnvironmentUtility.GetFriendlyPlatformName(platform)}"; Console.WriteLine(); + /**** + ** Check if correct installer + ****/ #if SMAPI_FOR_WINDOWS if (platform == Platform.Linux || platform == Platform.Mac) { @@ -182,78 +189,17 @@ namespace StardewModdingApi.Installer Console.ReadLine(); return; } -#endif - - /**** - ** read command-line arguments - ****/ - // get action from CLI - bool installArg = args.Contains("--install"); - bool uninstallArg = args.Contains("--uninstall"); - if (installArg && uninstallArg) - { - this.PrintError("You can't specify both --install and --uninstall command-line flags."); - Console.ReadLine(); - return; - } - - // get game path from CLI - string gamePathArg = null; - { - int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; - if (pathIndex >= 1 && args.Length >= pathIndex) - gamePathArg = args[pathIndex]; - } - - /**** - ** collect details - ****/ - // get game path - DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); - if (installDir == null) - { - this.PrintError("Failed finding your game path."); - Console.ReadLine(); - return; - } - - // get folders - DirectoryInfo packageDir = 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")); - DirectoryInfo modsDir = new DirectoryInfo(Path.Combine(installDir.FullName, "Mods")); - var paths = new - { - executable = Path.Combine(installDir.FullName, EnvironmentUtility.GetExecutableName(platform)), - unixSmapiLauncher = Path.Combine(installDir.FullName, "StardewModdingAPI"), - unixLauncher = Path.Combine(installDir.FullName, "StardewValley"), - unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original") - }; - - // show output - this.PrintInfo($"Your game folder: {installDir}."); - - /**** - ** validate assumptions - ****/ - if (!packageDir.Exists) - { - this.PrintError(platform == Platform.Windows && packageDir.FullName.Contains(Path.GetTempPath()) && packageDir.FullName.Contains(".zip") - ? "The installer is missing some files. It looks like you're running the installer from inside the downloaded zip; make sure you unzip the downloaded file first, then run the installer from the unzipped folder." - : $"The 'internal/{packageDir.Name}' package folder is missing (should be at {packageDir})." - ); - Console.ReadLine(); - return; - } - if (!File.Exists(paths.executable)) +#else + if (platform == Platform.Windows) { - this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + this.PrintError($"This is the installer for Linux/Mac. Run the 'install on Windows.exe' file instead."); Console.ReadLine(); return; } +#endif /**** - ** validate Windows dependencies + ** Check Windows dependencies ****/ if (platform == Platform.Windows) { @@ -276,159 +222,324 @@ namespace StardewModdingApi.Installer } } - Console.WriteLine(); - /**** - ** ask user what to do + ** read command-line arguments ****/ - ScriptAction action; + // get action from CLI + bool installArg = args.Contains("--install"); + bool uninstallArg = args.Contains("--uninstall"); + if (installArg && uninstallArg) + { + this.PrintError("You can't specify both --install and --uninstall command-line flags."); + Console.ReadLine(); + return; + } - if (installArg) - action = ScriptAction.Install; - else if (uninstallArg) - action = ScriptAction.Uninstall; - else + // get game path from CLI + string gamePathArg = null; + { + int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; + if (pathIndex >= 1 && args.Length >= pathIndex) + gamePathArg = args[pathIndex]; + } + + + /********* + ** Step 2: choose a theme (can't auto-detect on Linux/Mac) + *********/ + MonitorColorScheme scheme = MonitorColorScheme.AutoDetect; + if (platform == Platform.Linux || platform == Platform.Mac) { - this.PrintInfo("You can...."); - this.PrintInfo("[1] Install SMAPI."); - this.PrintInfo("[2] Uninstall SMAPI."); + /**** + ** print header + ****/ + this.PrintPlain("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); + this.PrintPlain("----------------------------------------------------------------------------"); + Console.WriteLine(); + + /**** + ** show theme selector + ****/ + // get theme writers + var lightBackgroundWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.LightBackground); + var darkDarkgroundWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.DarkBackground); + + // print question + this.PrintPlain("Which text looks more readable?"); + Console.WriteLine(); + Console.Write(" [1] "); + lightBackgroundWriter.WriteLine("Dark text on light background", ConsoleLogLevel.Info); + Console.Write(" [2] "); + darkDarkgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); Console.WriteLine(); - string choice = this.InteractivelyChoose("What do you want to do? Type 1 or 2, then press enter.", "1", "2"); + // handle choice + string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }); switch (choice) { case "1": - action = ScriptAction.Install; + scheme = MonitorColorScheme.LightBackground; + this.ConsoleWriter = lightBackgroundWriter; break; case "2": - action = ScriptAction.Uninstall; + scheme = MonitorColorScheme.DarkBackground; + this.ConsoleWriter = darkDarkgroundWriter; break; default: throw new InvalidOperationException($"Unexpected action key '{choice}'."); } - Console.WriteLine(); } + Console.Clear(); - /**** - ** Always uninstall old files - ****/ - // restore game launcher - if (platform.IsMono() && File.Exists(paths.unixLauncherBackup)) - { - this.PrintDebug("Removing SMAPI launcher..."); - this.InteractivelyDelete(paths.unixLauncher); - File.Move(paths.unixLauncherBackup, paths.unixLauncher); - } - // remove old files - string[] removePaths = this.GetUninstallPaths(installDir, modsDir) - .Where(path => Directory.Exists(path) || File.Exists(path)) - .ToArray(); - if (removePaths.Any()) + /********* + ** Step 3: find game folder + *********/ + InstallerPaths paths; { - this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); - foreach (string path in removePaths) - this.InteractivelyDelete(path); + /**** + ** print header + ****/ + this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just a few questions first."); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); + + /**** + ** collect details + ****/ + // get game path + this.PrintInfo("Where is your game folder?"); + DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); + if (installDir == null) + { + this.PrintError("Failed finding your game path."); + Console.ReadLine(); + return; + } + + // 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)); } + Console.Clear(); - /**** - ** Install new files - ****/ - if (action == ScriptAction.Install) + + /********* + ** Step 4: validate assumptions + *********/ { - // copy SMAPI files to game dir - this.PrintDebug("Adding SMAPI files..."); - foreach (FileInfo sourceFile in packageDir.EnumerateFiles().Where(this.ShouldCopyFile)) + if (!paths.PackageDir.Exists) { - if (sourceFile.Name == this.InstallerFileName) - continue; + 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; + } - string targetPath = Path.Combine(installDir.FullName, sourceFile.Name); - this.InteractivelyDelete(targetPath); - sourceFile.CopyTo(targetPath); + if (!File.Exists(paths.ExecutablePath)) + { + this.PrintError("The detected game install path doesn't contain a Stardew Valley executable."); + Console.ReadLine(); + return; } + } + + + /********* + ** Step 5: ask what to do + *********/ + ScriptAction action; + { + /**** + ** print header + ****/ + this.PrintInfo("Hi there! I'll help you install or remove SMAPI. Just one question first."); + this.PrintDebug($"Game path: {paths.GamePath}"); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); - // replace mod launcher (if possible) - if (platform.IsMono()) + /**** + ** ask what to do + ****/ + if (installArg) + action = ScriptAction.Install; + else if (uninstallArg) + action = ScriptAction.Uninstall; + else { - this.PrintDebug("Safely replacing game launcher..."); - if (File.Exists(paths.unixLauncher)) + this.PrintInfo("What do you want to do?"); + Console.WriteLine(); + this.PrintInfo("[1] Install SMAPI."); + this.PrintInfo("[2] Uninstall SMAPI."); + Console.WriteLine(); + + string choice = this.InteractivelyChoose("Type 1 or 2, then press enter.", new[] { "1", "2" }); + switch (choice) { - if (!File.Exists(paths.unixLauncherBackup)) - File.Move(paths.unixLauncher, paths.unixLauncherBackup); - else - this.InteractivelyDelete(paths.unixLauncher); + case "1": + action = ScriptAction.Install; + break; + case "2": + action = ScriptAction.Uninstall; + break; + default: + throw new InvalidOperationException($"Unexpected action key '{choice}'."); } + } + } + Console.Clear(); + - File.Move(paths.unixSmapiLauncher, paths.unixLauncher); + /********* + ** Step 6: apply + *********/ + { + /**** + ** print header + ****/ + this.PrintInfo($"That's all I need! I'll {action.ToString().ToLower()} SMAPI now."); + this.PrintDebug($"Game path: {paths.GamePath}"); + this.PrintDebug($"Color scheme: {this.GetDisplayText(scheme)}"); + this.PrintDebug("----------------------------------------------------------------------------"); + Console.WriteLine(); + + /**** + ** Always uninstall old files + ****/ + // restore game launcher + if (platform.IsMono() && File.Exists(paths.UnixBackupLauncherPath)) + { + this.PrintDebug("Removing SMAPI launcher..."); + this.InteractivelyDelete(paths.UnixLauncherPath); + File.Move(paths.UnixBackupLauncherPath, paths.UnixLauncherPath); } - // create mods directory (if needed) - if (!modsDir.Exists) + // remove old files + string[] removePaths = this.GetUninstallPaths(paths.GameDir, paths.ModsDir) + .Where(path => Directory.Exists(path) || File.Exists(path)) + .ToArray(); + if (removePaths.Any()) { - this.PrintDebug("Creating mods directory..."); - modsDir.Create(); + this.PrintDebug(action == ScriptAction.Install ? "Removing previous SMAPI files..." : "Removing SMAPI files..."); + foreach (string path in removePaths) + this.InteractivelyDelete(path); } - // add or replace bundled mods - modsDir.Create(); - DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "Mods")); - if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any()) + /**** + ** Install new files + ****/ + if (action == ScriptAction.Install) { - this.PrintDebug("Adding bundled mods..."); + // copy SMAPI files to game dir + this.PrintDebug("Adding SMAPI files..."); + foreach (FileInfo sourceFile in paths.PackageDir.EnumerateFiles().Where(this.ShouldCopyFile)) + { + if (sourceFile.Name == this.InstallerFileName) + continue; + + string targetPath = Path.Combine(paths.GameDir.FullName, sourceFile.Name); + this.InteractivelyDelete(targetPath); + sourceFile.CopyTo(targetPath); + } - // special case: rename Omegasis' SaveBackup mod + // replace mod launcher (if possible) + if (platform.IsMono()) { - DirectoryInfo oldFolder = new DirectoryInfo(Path.Combine(modsDir.FullName, "SaveBackup")); - DirectoryInfo newFolder = new DirectoryInfo(Path.Combine(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)) + this.PrintDebug("Safely replacing game launcher..."); + if (File.Exists(paths.UnixLauncherPath)) { - this.PrintDebug($" moving {oldFolder.Name} to {newFolder.Name}..."); - this.Move(oldFolder, newFolder.FullName); + if (!File.Exists(paths.UnixBackupLauncherPath)) + File.Move(paths.UnixLauncherPath, paths.UnixBackupLauncherPath); + else + this.InteractivelyDelete(paths.UnixLauncherPath); } + + File.Move(paths.UnixSmapiLauncherPath, paths.UnixLauncherPath); } - // add bundled mods - foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories()) + // create mods directory (if needed) + if (!paths.ModsDir.Exists) { - this.PrintDebug($" adding {sourceDir.Name}..."); + this.PrintDebug("Creating mods directory..."); + paths.ModsDir.Create(); + } + + // add or replace bundled mods + DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(paths.PackageDir.FullName, "Mods")); + if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any()) + { + this.PrintDebug("Adding bundled mods..."); - // init/clear target dir - DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(modsDir.FullName, sourceDir.Name)); - if (targetDir.Exists) + // special case: rename Omegasis' SaveBackup mod { - this.ProtectBundledFiles.TryGetValue(targetDir.Name, out HashSet<string> protectedFiles); - foreach (FileSystemInfo entry in targetDir.EnumerateFileSystemInfos()) + 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)) { - if (protectedFiles == null || !protectedFiles.Contains(entry.Name)) - this.InteractivelyDelete(entry.FullName); + this.PrintDebug($" moving {oldFolder.Name} to {newFolder.Name}..."); + this.Move(oldFolder, newFolder.FullName); } } - else - targetDir.Create(); - // copy files - foreach (FileInfo sourceFile in sourceDir.EnumerateFiles().Where(this.ShouldCopyFile)) - sourceFile.CopyTo(Path.Combine(targetDir.FullName, sourceFile.Name)); + // 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) + { + 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); + } + } + else + targetDir.Create(); + + // copy files + foreach (FileInfo sourceFile in sourceDir.EnumerateFiles().Where(this.ShouldCopyFile)) + sourceFile.CopyTo(Path.Combine(targetDir.FullName, sourceFile.Name)); + } + + // 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); + } } - } - // remove obsolete appdata mods - this.InteractivelyRemoveAppDataMods(modsDir, packagedModsDir); + // remove obsolete appdata mods + this.InteractivelyRemoveAppDataMods(paths.ModsDir, packagedModsDir); + } } Console.WriteLine(); Console.WriteLine(); - /**** - ** final instructions - ****/ + + /********* + ** Step 7: final instructions + *********/ if (platform == Platform.Windows) { if (action == ScriptAction.Install) { this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):"); - this.PrintSuccess($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%"); + this.PrintSuccess($" \"{Path.Combine(paths.GamePath, "StardewModdingAPI.exe")}\" %command%"); Console.WriteLine(); this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods."); } @@ -460,6 +571,23 @@ namespace StardewModdingApi.Installer return str; } + /// <summary>Get the display text for a color scheme.</summary> + /// <param name="scheme">The color scheme.</param> + private string GetDisplayText(MonitorColorScheme scheme) + { + switch (scheme) + { + case MonitorColorScheme.AutoDetect: + return "auto-detect"; + case MonitorColorScheme.DarkBackground: + return "light text on dark background"; + case MonitorColorScheme.LightBackground: + return "dark text on light background"; + default: + return scheme.ToString(); + } + } + /// <summary>Get the value of a key in the Windows HKLM registry.</summary> /// <param name="key">The full path of the registry key relative to HKLM.</param> /// <param name="name">The name of the value.</param> @@ -486,6 +614,10 @@ namespace StardewModdingApi.Installer return (string)openKey.GetValue(name); } + /// <summary>Print a message without formatting.</summary> + /// <param name="text">The text to print.</param> + private void PrintPlain(string text) => Console.WriteLine(text); + /// <summary>Print a debug message.</summary> /// <param name="text">The text to print.</param> private void PrintDebug(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug); @@ -594,17 +726,22 @@ namespace StardewModdingApi.Installer } /// <summary>Interactively ask the user to choose a value.</summary> + /// <param name="print">A callback which prints a message to the console.</param> /// <param name="message">The message to print.</param> /// <param name="options">The allowed options (not case sensitive).</param> - private string InteractivelyChoose(string message, params string[] options) + /// <param name="indent">The indentation to prefix to output.</param> + private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null) { + print = print ?? this.PrintInfo; + while (true) { - this.PrintInfo(message); + print(indent + message); + Console.Write(indent); string input = Console.ReadLine()?.Trim().ToLowerInvariant(); if (!options.Contains(input)) { - this.PrintInfo("That's not a valid option."); + print($"{indent}That's not a valid option."); continue; } return input; diff --git a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj index 2ad7e82a..e82c6093 100644 --- a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj +++ b/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj @@ -40,6 +40,7 @@ <Link>Properties\GlobalAssemblyInfo.cs</Link> </Compile> <Compile Include="Enums\ScriptAction.cs" /> + <Compile Include="Framework\InstallerPaths.cs" /> <Compile Include="InteractiveInstaller.cs" /> <Compile Include="Program.cs" /> <Compile Include="Properties\AssemblyInfo.cs" /> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs index 797d4650..7ee662d0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData +namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData { /// <summary>An item type that can be searched and added to the player through the console.</summary> internal enum ItemType @@ -9,9 +9,6 @@ /// <summary>A <see cref="Boots"/> item.</summary> Boots, - /// <summary>A fish item.</summary> - Fish, - /// <summary>A <see cref="Wallpaper"/> flooring item.</summary> Flooring, diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index e678d057..7a3d8694 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -37,14 +37,15 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset, new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 1, new Shears()); yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 2, new Pan()); + yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 3, new Wand()); // wallpapers for (int id = 0; id < 112; id++) - yield return new SearchableItem(ItemType.Wallpaper, id, new Wallpaper(id)); + yield return new SearchableItem(ItemType.Wallpaper, id, new Wallpaper(id) { Category = SObject.furnitureCategory }); // flooring for (int id = 0; id < 40; id++) - yield return new SearchableItem(ItemType.Flooring, id, new Wallpaper(id, isFloor: true)); + yield return new SearchableItem(ItemType.Flooring, id, new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory }); // equipment foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys) @@ -75,10 +76,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework yield return new SearchableItem(ItemType.Furniture, id, new Furniture(id, Vector2.Zero)); } - // fish - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Fish").Keys) - yield return new SearchableItem(ItemType.Fish, id, new SObject(id, 999)); - // craftables foreach (int id in Game1.bigCraftablesInformation.Keys) yield return new SearchableItem(ItemType.BigCraftable, id, new SObject(Vector2.Zero, id)); @@ -103,16 +100,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework yield return new SearchableItem(ItemType.Object, id, item); // fruit products - if (item.category == SObject.FruitsCategory) + if (item.Category == SObject.FruitsCategory) { // wine SObject wine = new SObject(348, 1) { Name = $"{item.Name} Wine", - Price = item.price * 3 + Price = item.Price * 3 }; wine.preserve.Value = SObject.PreserveType.Wine; - wine.preservedParentSheetIndex.Value = item.parentSheetIndex; + wine.preservedParentSheetIndex.Value = item.ParentSheetIndex; yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, wine); // jelly @@ -122,21 +119,21 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework Price = 50 + item.Price * 2 }; jelly.preserve.Value = SObject.PreserveType.Jelly; - jelly.preservedParentSheetIndex.Value = item.parentSheetIndex; + jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex; yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, jelly); } // vegetable products - else if (item.category == SObject.VegetableCategory) + else if (item.Category == SObject.VegetableCategory) { // juice SObject juice = new SObject(350, 1) { Name = $"{item.Name} Juice", - Price = (int)(item.price * 2.25d) + Price = (int)(item.Price * 2.25d) }; juice.preserve.Value = SObject.PreserveType.Juice; - juice.preservedParentSheetIndex.Value = item.parentSheetIndex; + juice.preservedParentSheetIndex.Value = item.ParentSheetIndex; yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, juice); // pickled @@ -146,16 +143,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework Price = 50 + item.Price * 2 }; pickled.preserve.Value = SObject.PreserveType.Pickle; - pickled.preservedParentSheetIndex.Value = item.parentSheetIndex; + pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex; yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, pickled); } // flower honey - else if (item.category == SObject.flowersCategory) + else if (item.Category == SObject.flowersCategory) { // get honey type SObject.HoneyType? type = null; - switch (item.parentSheetIndex) + switch (item.ParentSheetIndex) { case 376: type = SObject.HoneyType.Poppy; diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index f89049c6..a6c5cd88 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,8 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "2.6.0", + "Version": "2.7.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", - "EntryDll": "ConsoleCommands.dll" + "EntryDll": "ConsoleCommands.dll", + "MinimumApiVersion": "2.7.0" } diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index ee0f2abb..e973b449 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,8 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "2.6.0", + "Version": "2.7.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", - "EntryDll": "SaveBackup.dll" + "EntryDll": "SaveBackup.dll", + "MinimumApiVersion": "2.7.0" } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index b500e19d..18d55665 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -83,31 +83,25 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Fetch version metadata for the given mods.</summary> /// <param name="model">The mod search criteria.</param> [HttpPost] - public async Task<object> PostAsync([FromBody] ModSearchModel model) + public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model) { - // parse request data - ISemanticVersion apiVersion = this.GetApiVersion(); - ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray(); + if (model?.Mods == null) + return new ModEntryModel[0]; // fetch wiki data WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync(); - - // fetch data IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); - foreach (ModSearchEntryModel mod in searchMods) + foreach (ModSearchEntryModel mod in model.Mods) { if (string.IsNullOrWhiteSpace(mod.ID)) continue; ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata); - result.SetBackwardsCompatibility(apiVersion); mods[mod.ID] = result; } - // return in expected structure - return apiVersion.IsNewerThan("2.6-beta.18") - ? mods.Values - : (object)mods; + // return data + return mods.Values; } @@ -231,29 +225,6 @@ namespace StardewModdingAPI.Web.Controllers return current != null && (other == null || other.IsOlderThan(current)); } - /// <summary>Get the mods for which the API should return data.</summary> - /// <param name="model">The search model.</param> - /// <param name="apiVersion">The requested API version.</param> - private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion) - { - if (model == null) - yield break; - - // yield standard entries - if (model.Mods != null) - { - foreach (ModSearchEntryModel mod in model.Mods) - yield return mod; - } - - // yield mod update keys if backwards compatible - if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17")) - { - foreach (string updateKey in model.ModKeys.Distinct()) - yield return new ModSearchEntryModel(updateKey, new[] { updateKey }); - } - } - /// <summary>Get mod data from the wiki compatibility list.</summary> private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync() { diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json index e72efb39..c95abe75 100644 --- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json @@ -51,6 +51,23 @@ * mod is no longer compatible. */ "ModData": { + /********* + ** Content packs + *********/ + "Canon-Friendly Dialogue Expansion": { + "ID": "gizzymo.canonfriendlyexpansion", + "~1.1.1 | Status": "AssumeBroken" // causes a save crash on certain dates + }, + + "Everytime Submarine": { + "ID": "MustafaDemirel.EverytimeSubmarine", + "~1.0.0 | Status": "AssumeBroken" // breaks player saves if their beach bridge is fixed + }, + + + /********* + ** Mods + *********/ "AccessChestAnywhere": { "ID": "AccessChestAnywhere", "MapLocalVersions": { "1.1-1078": "1.1" }, @@ -822,7 +839,7 @@ }, "Level Extender": { - "ID": "DevinLematty.LevelExtender", + "ID": "DevinLematty.LevelExtender", "FormerIDs": "Devin Lematty.Level Extender", // changed in 1.3 "Default | UpdateKey": "Nexus:1471" }, @@ -1046,7 +1063,7 @@ }, "One Click Shed": { - "ID": "BitwiseJonMods.OneClickShedReloader", + "ID": "BitwiseJonMods.OneClickShedReloader", "Default | UpdateKey": "Nexus:2052" }, diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index a6cddbe4..bd512fb1 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.6.0"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.7.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.27"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.28"); /// <summary>The maximum supported version of Stardew Valley.</summary> public static ISemanticVersion MaximumGameVersion { get; } = null; @@ -70,11 +70,14 @@ namespace StardewModdingAPI /// <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"); - /// <summary>The filename prefix for SMAPI log files.</summary> - internal static string LogNamePrefix { get; } = "SMAPI-latest"; + /// <summary>The filename prefix used for all SMAPI logs.</summary> + internal static string LogNamePrefix { get; } = "SMAPI-"; + + /// <summary>The filename for SMAPI's main log, excluding the <see cref="LogExtension"/>.</summary> + internal static string LogFilename { get; } = $"{Constants.LogNamePrefix}latest"; /// <summary>The filename extension for SMAPI log files.</summary> - internal static string LogNameExtension { get; } = "txt"; + internal static string LogExtension { get; } = "txt"; /// <summary>A copy of the log leading up to the previous fatal crash, if any.</summary> internal static string FatalCrashLog => Path.Combine(Constants.LogDir, "SMAPI-crash.txt"); @@ -86,7 +89,7 @@ namespace StardewModdingAPI internal static string UpdateMarker => Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.update.marker"); /// <summary>The full path to the folder containing mods.</summary> - internal static string ModPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); + internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); /// <summary>The game's current semantic version.</summary> internal static ISemanticVersion GameVersion { get; } = new GameVersion(Constants.GetGameVersion()); diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index d9b2109a..9eb7b5f9 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -9,6 +9,7 @@ using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Metadata; +using StardewModdingAPI.Toolkit.Serialisation; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -32,6 +33,9 @@ namespace StardewModdingAPI.Framework /// <summary>Simplifies access to private code.</summary> private readonly Reflector Reflection; + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> private readonly IList<IContentManager> ContentManagers = new List<IContentManager>(); @@ -67,10 +71,12 @@ namespace StardewModdingAPI.Framework /// <param name="currentCulture">The current culture for which to localise content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection) + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) { this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; + this.JsonHelper = jsonHelper; this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing) @@ -92,7 +98,7 @@ namespace StardewModdingAPI.Framework /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> public ModContentManager CreateModContentManager(string name, string rootDirectory) { - ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing); + ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.JsonHelper, this.OnDisposing); this.ContentManagers.Add(manager); return manager; } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 80bf37e9..24ce69ea 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -5,6 +5,7 @@ using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; using StardewValley; namespace StardewModdingAPI.Framework.ContentManagers @@ -13,6 +14,13 @@ namespace StardewModdingAPI.Framework.ContentManagers internal class ModContentManager : BaseContentManager { /********* + ** Properties + *********/ + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + private readonly JsonHelper JsonHelper; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> @@ -23,9 +31,13 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) { } + public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) + { + this.JsonHelper = jsonHelper; + } /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> @@ -95,9 +107,14 @@ namespace StardewModdingAPI.Framework.ContentManagers case ".xnb": return base.Load<T>(relativePath, language); - // unpacked map - case ".tbin": - throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + // unpacked data + case ".json": + { + if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data)) + throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above + + return data; + } // unpacked image case ".png": @@ -114,6 +131,10 @@ namespace StardewModdingAPI.Framework.ContentManagers return (T)(object)texture; } + // unpacked map + case ".tbin": + throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + default: throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.png', '.tbin', or '.xnb'."); } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index 4a4adb90..62d8b80d 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -54,7 +54,9 @@ namespace StardewModdingAPI.Framework public TModel ReadJsonFile<TModel>(string path) where TModel : class { path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - return this.JsonHelper.ReadJsonFile<TModel>(path); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model) + ? model + : null; } /// <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> diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 671dc21e..a8b24a13 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -259,7 +259,7 @@ namespace StardewModdingAPI.Framework.ModHelpers // get seasonal name (if applicable) string seasonalImageSource = null; - if (Game1.currentSeason != null) + if (Context.IsSaveLoaded && Game1.currentSeason != null) { string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); bool hasSeasonalPrefix = diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index d9498e83..0ba258b4 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -138,7 +138,9 @@ namespace StardewModdingAPI.Framework.ModHelpers where TModel : class { path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - return this.JsonHelper.ReadJsonFile<TModel>(path); + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + ? data + : null; } /// <summary>Save to a JSON file.</summary> diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index cf5a3175..3a26660f 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -70,7 +70,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders // method reference MethodReference methodReference = RewriteHelper.AsMethodReference(instruction); - if (methodReference != null && this.ShouldValidate(methodReference.DeclaringType)) + if (methodReference != null && !this.IsUnsupported(methodReference) && this.ShouldValidate(methodReference.DeclaringType)) { // get potential targets MethodDefinition[] candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray(); @@ -106,6 +106,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); } + /// <summary>Get whether a method reference is a special case that's not currently supported (e.g. array methods).</summary> + /// <param name="method">The method reference.</param> + private bool IsUnsupported(MethodReference method) + { + return + method.DeclaringType.Name.Contains("["); // array methods + } + /// <summary>Get a shorter type name for display.</summary> /// <param name="type">The type reference.</param> private string GetFriendlyTypeName(TypeReference type) diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 05fedc3d..83e8c9a7 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.StateTracking; using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Toolkit.Serialisation; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Buildings; @@ -37,16 +38,6 @@ namespace StardewModdingAPI.Framework ** Properties *********/ /**** - ** Constructor hack - ****/ - /// <summary>A static instance of <see cref="Monitor"/> to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> - internal static IMonitor MonitorDuringInitialisation; - - /// <summary>A static instance of <see cref="Reflection"/> to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> - internal static Reflector ReflectorDuringInitialisation; - - - /**** ** SMAPI state ****/ /// <summary>Encapsulates monitoring and logging.</summary> @@ -83,6 +74,9 @@ 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 ****/ @@ -105,6 +99,9 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ + /// <summary>Static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + internal static SGameConstructorHack ConstructorHack { get; set; } + /// <summary>SMAPI's content manager.</summary> public ContentCoordinator ContentCore { get; private set; } @@ -132,10 +129,13 @@ namespace StardewModdingAPI.Framework /// <param name="monitor">Encapsulates monitoring and logging.</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="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, Action onGameInitialised, Action onGameExiting) + internal SGame(IMonitor monitor, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, Action onGameInitialised, Action onGameExiting) { + SGame.ConstructorHack = null; + // check expectations if (this.ContentCore == null) throw new InvalidOperationException($"The game didn't initialise its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); @@ -147,6 +147,7 @@ namespace StardewModdingAPI.Framework this.Monitor = monitor; this.Events = eventManager; this.Reflection = reflection; + this.JsonHelper = jsonHelper; this.OnGameInitialised = onGameInitialised; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); @@ -191,8 +192,7 @@ namespace StardewModdingAPI.Framework // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialised at this point. if (this.ContentCore == null) { - this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.MonitorDuringInitialisation, SGame.ReflectorDuringInitialisation); - SGame.MonitorDuringInitialisation = null; + this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper); this.NextContentManagerIsMain = true; return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); } diff --git a/src/SMAPI/Framework/SGameConstructorHack.cs b/src/SMAPI/Framework/SGameConstructorHack.cs new file mode 100644 index 00000000..494bab99 --- /dev/null +++ b/src/SMAPI/Framework/SGameConstructorHack.cs @@ -0,0 +1,37 @@ +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Serialisation; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>The static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + internal class SGameConstructorHack + { + /********* + ** Accessors + *********/ + /// <summary>Encapsulates monitoring and logging.</summary> + public IMonitor Monitor { get; } + + /// <summary>Simplifies access to private game code.</summary> + public Reflector Reflection { get; } + + /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> + public JsonHelper JsonHelper { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="reflection">Simplifies access to private game code.</param> + /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> + public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) + { + this.Monitor = monitor; + this.Reflection = reflection; + this.JsonHelper = jsonHelper; + } + } +} diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 12abeb10..8487b6be 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -13,6 +13,7 @@ using StardewValley.Menus; using StardewValley.Objects; using StardewValley.Projectiles; using StardewValley.TerrainFeatures; +using xTile.Tiles; namespace StardewModdingAPI.Metadata { @@ -63,6 +64,23 @@ namespace StardewModdingAPI.Metadata /// <returns>Returns any non-null value to indicate an asset was loaded.</returns> private object PropagateImpl(LocalizedContentManager content, string key) { + /**** + ** Special case: current map tilesheet + ** We only need to do this for the current location, since tilesheets are reloaded when you enter a location. + ** Just in case, we should still propagate by key even if a tilesheet is matched. + ****/ + if (Game1.currentLocation?.map?.TileSheets != null) + { + foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) + { + if (this.GetNormalisedPath(tilesheet.ImageSource) == key) + Game1.mapDisplayDevice.LoadTileSheet(tilesheet); + } + } + + /**** + ** Propagate by key + ****/ Reflector reflection = this.Reflection; switch (key.ToLower().Replace("/", "\\")) // normalised key so we can compare statically { @@ -313,21 +331,21 @@ namespace StardewModdingAPI.Metadata if (this.IsInFolder(key, "Buildings")) return this.ReloadBuildings(content, key); - if (this.IsInFolder(key, "Characters")) - return this.ReloadNpcSprites(content, key, monster: false); - - if (this.IsInFolder(key, "Characters\\Monsters")) - return this.ReloadNpcSprites(content, key, monster: true); + if (this.IsInFolder(key, "Characters") || this.IsInFolder(key, "Characters\\Monsters")) + return this.ReloadNpcSprites(content, key); - if (key.StartsWith(this.GetNormalisedPath("LooseSprites\\Fence"), StringComparison.InvariantCultureIgnoreCase)) - return this.ReloadFenceTextures(content, key); + if (this.KeyStartsWith(key, "LooseSprites\\Fence")) + return this.ReloadFenceTextures(key); if (this.IsInFolder(key, "Portraits")) return this.ReloadNpcPortraits(content, key); // dynamic data + if (this.IsInFolder(key, "Characters\\Dialogue")) + return this.ReloadNpcDialogue(key); + if (this.IsInFolder(key, "Characters\\schedules")) - return this.ReloadNpcSchedules(content, key); + return this.ReloadNpcSchedules(key); return false; } @@ -416,10 +434,9 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the sprites for a fence type.</summary> - /// <param name="content">The content manager through which to reload the asset.</param> /// <param name="key">The asset key to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadFenceTextures(LocalizedContentManager content, string key) + private bool ReloadFenceTextures(string key) { // get fence type if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) @@ -446,13 +463,13 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching NPCs.</summary> /// <param name="content">The content manager through which to reload the asset.</param> /// <param name="key">The asset key to reload.</param> - /// <param name="monster">Whether to match monsters (<c>true</c>) or non-monsters (<c>false</c>).</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadNpcSprites(LocalizedContentManager content, string key, bool monster) + private bool ReloadNpcSprites(LocalizedContentManager content, string key) { // get NPCs - string name = this.GetNpcNameFromFileName(Path.GetFileName(key)); - NPC[] characters = this.GetCharacters().Where(npc => npc.Name == name && npc.IsMonster == monster).ToArray(); + NPC[] characters = this.GetCharacters() + .Where(npc => this.GetNormalisedPath(npc.Sprite.textureName.Value) == key) + .ToArray(); if (!characters.Any()) return false; @@ -470,15 +487,20 @@ namespace StardewModdingAPI.Metadata private bool ReloadNpcPortraits(LocalizedContentManager content, string key) { // get NPCs - string name = this.GetNpcNameFromFileName(Path.GetFileName(key)); - NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); + NPC[] villagers = this.GetCharacters() + .Where(npc => npc.isVillager() && this.GetNormalisedPath($"Portraits\\{this.Reflection.GetMethod(npc, "getTextureName").Invoke<string>()}") == key) + .ToArray(); if (!villagers.Any()) return false; // update portrait Texture2D texture = content.Load<Texture2D>(key); foreach (NPC villager in villagers) + { + villager.resetPortrait(); villager.Portrait = texture; + } + return true; } @@ -508,11 +530,27 @@ namespace StardewModdingAPI.Metadata /**** ** Reload data methods ****/ + /// <summary>Reload the dialogue data for matching NPCs.</summary> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether any assets were reloaded.</returns> + private bool ReloadNpcDialogue(string key) + { + // get NPCs + string name = Path.GetFileName(key); + NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); + if (!villagers.Any()) + return false; + + // update dialogue + foreach (NPC villager in villagers) + villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue + return true; + } + /// <summary>Reload the schedules for matching NPCs.</summary> - /// <param name="content">The content manager through which to reload the asset.</param> /// <param name="key">The asset key to reload.</param> /// <returns>Returns whether any assets were reloaded.</returns> - private bool ReloadNpcSchedules(LocalizedContentManager content, string key) + private bool ReloadNpcSchedules(string key) { // get NPCs string name = Path.GetFileName(key); @@ -607,6 +645,14 @@ namespace StardewModdingAPI.Metadata } } + /// <summary>Get whether a key starts with a substring after the substring is normalised.</summary> + /// <param name="key">The key to check.</param> + /// <param name="rawSubstring">The substring to normalise and find.</param> + private bool KeyStartsWith(string key, string rawSubstring) + { + return key.StartsWith(this.GetNormalisedPath(rawSubstring), StringComparison.InvariantCultureIgnoreCase); + } + /// <summary>Get whether a normalised asset key is in the given folder.</summary> /// <param name="key">The normalised asset key (like <c>Animals/cat</c>).</param> /// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalised.</param> @@ -614,7 +660,7 @@ namespace StardewModdingAPI.Metadata private bool IsInFolder(string key, string folder, bool allowSubfolders = false) { return - key.StartsWith(this.GetNormalisedPath($"{folder}\\"), StringComparison.InvariantCultureIgnoreCase) + this.KeyStartsWith(key, $"{folder}\\") && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); } diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 6012b15a..634c5066 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -11,7 +11,6 @@ using System.Security; using System.Text; using System.Text.RegularExpressions; using System.Threading; -using Microsoft.Xna.Framework.Input; #if SMAPI_FOR_WINDOWS using System.Windows.Forms; #endif @@ -32,10 +31,8 @@ using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; -using Keys = Microsoft.Xna.Framework.Input.Keys; using Monitor = StardewModdingAPI.Framework.Monitor; using SObject = StardewValley.Object; using ThreadState = System.Threading.ThreadState; @@ -103,6 +100,9 @@ namespace StardewModdingAPI /// <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 @@ -116,18 +116,34 @@ namespace StardewModdingAPI // 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(writeToConsole)) + using (Program program = new Program(modsPath, writeToConsole)) program.RunInteractively(); } /// <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(bool writeToConsole) + public Program(string modsPath, bool writeToConsole) { // init paths - this.VerifyPath(Constants.ModPath); + this.VerifyPath(modsPath); this.VerifyPath(Constants.LogDir); + this.ModsPath = modsPath; // init log file this.PurgeLogFiles(); @@ -146,7 +162,9 @@ namespace StardewModdingAPI // 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: {Constants.ModPath}"); + 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 @@ -193,9 +211,6 @@ namespace StardewModdingAPI // init JSON parser JsonConverter[] converters = { - new StringEnumConverter<Buttons>(), - new StringEnumConverter<Keys>(), - new StringEnumConverter<SButton>(), new ColorConverter(), new PointConverter(), new RectangleConverter() @@ -214,9 +229,8 @@ namespace StardewModdingAPI AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); // override game - SGame.MonitorDuringInitialisation = this.Monitor; - SGame.ReflectorDuringInitialisation = this.Reflection; - this.GameInstance = new SGame(this.Monitor, this.Reflection, this.EventManager, this.InitialiseAfterGameStart, this.Dispose); + 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 @@ -418,7 +432,7 @@ namespace StardewModdingAPI ModResolver resolver = new ModResolver(); // load manifests - IModMetadata[] mods = resolver.ReadManifests(toolkit, Constants.ModPath, modDatabase).ToArray(); + IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); resolver.ValidateManifests(mods, Constants.ApiVersion, toolkit.GetUpdateUrl); // process dependencies @@ -435,10 +449,10 @@ namespace StardewModdingAPI Exported = DateTime.UtcNow.ToString("O"), ApiVersion = Constants.ApiVersion.ToString(), GameVersion = Constants.GameVersion.ToString(), - ModFolderPath = Constants.ModPath, + ModFolderPath = this.ModsPath, Mods = mods }; - this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.metadata-dump.json"), export); + this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export); } // check for updates @@ -746,7 +760,7 @@ namespace StardewModdingAPI // load content packs foreach (IModMetadata metadata in mods.Where(p => p.IsContentPack)) { - this.Monitor.Log($" {metadata.DisplayName} (content pack, {PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)})...", LogLevel.Trace); + 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()) @@ -791,7 +805,7 @@ namespace StardewModdingAPI // get basic info IManifest manifest = metadata.Manifest; this.Monitor.Log(metadata.Manifest?.EntryDll != null - ? $" {metadata.DisplayName} ({PathUtilities.GetRelativePath(Constants.ModPath, metadata.DirectoryPath)}{Path.DirectorySeparatorChar}{metadata.Manifest.EntryDll})..." // don't use Path.Combine here, since EntryDLL might not be valid + ? $" {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 @@ -884,33 +898,15 @@ namespace StardewModdingAPI } IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); - // log skipped mods - this.Monitor.Newline(); - if (skippedMods.Any()) - { - this.Monitor.Log($"Skipped {skippedMods.Count} mods:", LogLevel.Error); - foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName)) - { - IModMetadata mod = pair.Key; - string[] reason = pair.Value; - - 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 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}" : ""), + + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), LogLevel.Info ); } @@ -936,27 +932,8 @@ namespace StardewModdingAPI this.Monitor.Newline(); } - // log warnings - { - IModMetadata[] modsWithWarnings = this.ModRegistry.GetAll().Where(p => p.Warnings != ModWarning.None).ToArray(); - if (modsWithWarnings.Any()) - { - this.Monitor.Log($"Found issues with {modsWithWarnings.Length} mods:", LogLevel.Warn); - foreach (IModMetadata metadata in modsWithWarnings) - { - string[] warnings = this.GetWarningText(metadata.Warnings).ToArray(); - if (warnings.Length == 1) - this.Monitor.Log($" {metadata.DisplayName} {warnings[0]}", LogLevel.Warn); - else - { - this.Monitor.Log($" {metadata.DisplayName}:", LogLevel.Warn); - foreach (string warning in warnings) - this.Monitor.Log(" - " + warning, LogLevel.Warn); - } - } - this.Monitor.Newline(); - } - } + // log mod warnings + this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); // initialise translations this.ReloadTranslations(loadedMods); @@ -1047,23 +1024,87 @@ namespace StardewModdingAPI this.ModRegistry.AreAllModsInitialised = true; } - /// <summary>Get the warning text for a mod warning bit mask.</summary> - /// <param name="mask">The mod warning bit mask.</param> - private IEnumerable<string> GetWarningText(ModWarning mask) + /// <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) { - if (mask.HasFlag(ModWarning.BrokenCodeLoaded)) - yield return "has broken code, but SMAPI is configured to allow it anyway. The mod may crash or behave unexpectedly."; - if (mask.HasFlag(ModWarning.ChangesSaveSerialiser)) - yield return "accesses the save serialiser and may break your saves."; - if (mask.HasFlag(ModWarning.PatchesGame)) - yield return "patches the game. This may cause errors or bugs in-game. If you have issues, try removing this mod first."; - if (mask.HasFlag(ModWarning.UsesUnvalidatedUpdateTick)) - yield return "bypasses normal SMAPI event protections. This may cause errors or save corruption. If you have issues, try removing this mod first."; - if (mask.HasFlag(ModWarning.UsesDynamic)) - yield return "uses the 'dynamic' keyword. This won't work on Linux/Mac."; - if (mask.HasFlag(ModWarning.NoUpdateKeys)) - yield return "has no update keys in its manifest. SMAPI won't show update alerts for this mod."; + // 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> @@ -1118,7 +1159,10 @@ namespace StardewModdingAPI string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); try { - translations[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName); + 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) { @@ -1260,7 +1304,7 @@ namespace StardewModdingAPI { // default path { - FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.{Constants.LogNameExtension}")); + FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); if (!defaultFile.Exists) return defaultFile.FullName; } @@ -1268,7 +1312,7 @@ namespace StardewModdingAPI // get first disambiguated path for (int i = 2; i < int.MaxValue; i++) { - FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}.player-{i}.{Constants.LogNameExtension}")); + FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); if (!file.Exists) return file.FullName; } diff --git a/src/SMAPI/SButton.cs b/src/SMAPI/SButton.cs index 3f95169a..bc76c91d 100644 --- a/src/SMAPI/SButton.cs +++ b/src/SMAPI/SButton.cs @@ -604,21 +604,21 @@ namespace StardewModdingAPI *********/ /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> /// <param name="key">The keyboard button to convert.</param> - internal static SButton ToSButton(this Keys key) + public static SButton ToSButton(this Keys key) { return (SButton)key; } /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> /// <param name="key">The controller button to convert.</param> - internal static SButton ToSButton(this Buttons key) + public static SButton ToSButton(this Buttons key) { return (SButton)(SButtonExtensions.ControllerOffset + key); } /// <summary>Get the <see cref="SButton"/> equivalent for the given button.</summary> /// <param name="input">The Stardew Valley button to convert.</param> - internal static SButton ToSButton(this InputButton input) + public static SButton ToSButton(this InputButton input) { // derived from InputButton constructors if (input.mouseLeft) diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index 0d0a5fe9..fc2d45ba 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -109,6 +109,7 @@ <Compile Include="Events\WorldBuildingListChangedEventArgs.cs" /> <Compile Include="Events\WorldLocationListChangedEventArgs.cs" /> <Compile Include="Events\WorldObjectListChangedEventArgs.cs" /> + <Compile Include="Framework\SGameConstructorHack.cs" /> <Compile Include="Framework\ContentManagers\BaseContentManager.cs" /> <Compile Include="Framework\ContentManagers\GameContentManager.cs" /> <Compile Include="Framework\ContentManagers\IContentManager.cs" /> diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index f3f22b93..2aafe199 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,6 +1,3 @@ -using System; -using Newtonsoft.Json; - namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// <summary>Metadata about a mod.</summary> @@ -26,46 +23,5 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The errors that occurred while fetching update data.</summary> public string[] Errors { get; set; } = new string[0]; - - /**** - ** Backwards-compatible fields - ****/ - /// <summary>The mod's latest version number.</summary> - [Obsolete("Use " + nameof(ModEntryModel.Main))] - [JsonProperty] - internal string Version { get; private set; } - - /// <summary>The mod's web URL.</summary> - [Obsolete("Use " + nameof(ModEntryModel.Main))] - [JsonProperty] - internal string Url { get; private set; } - - /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary> - [Obsolete("Use " + nameof(ModEntryModel.Optional))] - [JsonProperty] - internal string PreviewVersion { get; private set; } - - /// <summary>The web URL to the mod's latest optional release, if newer than <see cref="Version"/>.</summary> - [Obsolete("Use " + nameof(ModEntryModel.Optional))] - [JsonProperty] - internal string PreviewUrl { get; private set; } - - - /********* - ** Public methods - *********/ - /// <summary>Set backwards-compatible fields.</summary> - /// <param name="version">The requested API version.</param> - public void SetBackwardsCompatibility(ISemanticVersion version) - { - if (version.IsOlderThan("2.6-beta.19")) - { - this.Version = this.Main?.Version?.ToString(); - this.Url = this.Main?.Url; - - this.PreviewVersion = this.Optional?.Version?.ToString(); - this.PreviewUrl = this.Optional?.Url; - } - } } } diff --git a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs index df0d8457..e352e1cc 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs @@ -1,4 +1,3 @@ -using System; using System.Linq; namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi @@ -9,10 +8,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /********* ** Accessors *********/ - /// <summary>The namespaced mod keys to search.</summary> - [Obsolete] - public string[] ModKeys { get; set; } - /// <summary>The mods for which to find data.</summary> public ModSearchEntryModel[] Mods { get; set; } diff --git a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs index de8d0f02..f1cce4a4 100644 --- a/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/StardewModdingAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -51,8 +51,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning { try { - manifest = this.JsonHelper.ReadJsonFile<Manifest>(manifestFile.FullName); - if (manifest == null) + if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest)) manifestError = "its manifest is invalid."; } catch (SParseException ex) diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs b/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs deleted file mode 100644 index 13e6e3a1..00000000 --- a/src/StardewModdingAPI.Toolkit/Serialisation/Converters/StringEnumConverter.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using Newtonsoft.Json.Converters; - -namespace StardewModdingAPI.Toolkit.Serialisation.Converters -{ - /// <summary>A variant of <see cref="StringEnumConverter"/> which only converts a specified enum.</summary> - /// <typeparam name="T">The enum type.</typeparam> - internal class StringEnumConverter<T> : StringEnumConverter - { - /********* - ** Public methods - *********/ - /// <summary>Get whether this instance can convert the specified object type.</summary> - /// <param name="type">The object type.</param> - public override bool CanConvert(Type type) - { - return - base.CanConvert(type) - && (Nullable.GetUnderlyingType(type) ?? type) == typeof(T); - } - } -} diff --git a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs index 00f334ad..cc8eeb73 100644 --- a/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs +++ b/src/StardewModdingAPI.Toolkit/Serialisation/JsonHelper.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; using StardewModdingAPI.Toolkit.Serialisation.Converters; namespace StardewModdingAPI.Toolkit.Serialisation @@ -17,7 +18,11 @@ namespace StardewModdingAPI.Toolkit.Serialisation { Formatting = Formatting.Indented, ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded - Converters = new List<JsonConverter> { new SemanticVersionConverter() } + Converters = new List<JsonConverter> + { + new SemanticVersionConverter(), + new StringEnumConverter() + } }; @@ -27,10 +32,11 @@ namespace StardewModdingAPI.Toolkit.Serialisation /// <summary>Read a JSON file.</summary> /// <typeparam name="TModel">The model type.</typeparam> /// <param name="fullPath">The absolete file path.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> - /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> - public TModel ReadJsonFile<TModel>(string fullPath) - where TModel : class + /// <param name="result">The parsed content model.</param> + /// <returns>Returns false if the file doesn't exist, else true.</returns> + /// <exception cref="ArgumentException">The given <paramref name="fullPath"/> is empty or invalid.</exception> + /// <exception cref="JsonReaderException">The file contains invalid JSON.</exception> + public bool ReadJsonFileIfExists<TModel>(string fullPath, out TModel result) { // validate if (string.IsNullOrWhiteSpace(fullPath)) @@ -44,13 +50,15 @@ namespace StardewModdingAPI.Toolkit.Serialisation } catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) { - return null; + result = default(TModel); + return false; } // deserialise model try { - return this.Deserialise<TModel>(json); + result = this.Deserialise<TModel>(json); + return true; } catch (Exception ex) { |