diff options
30 files changed, 754 insertions, 435 deletions
diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs index bc0ddf69..2e4f9373 100644 --- a/build/GlobalAssemblyInfo.cs +++ b/build/GlobalAssemblyInfo.cs @@ -1,5 +1,5 @@ using System.Reflection; [assembly: AssemblyProduct("SMAPI")] -[assembly: AssemblyVersion("2.6.0")] -[assembly: AssemblyFileVersion("2.6.0")] +[assembly: AssemblyVersion("2.7.0")] +[assembly: AssemblyFileVersion("2.7.0")] diff --git a/docs/release-notes.md b/docs/release-notes.md index e4f9fd1d..133006e8 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,4 +1,33 @@ # Release notes +## 2.7 +* For players: + * Updated for Stardew Valley 1.3.28. + * Improved how mod issues are listed in the console and log. + * Revamped installer. It now... + * uses a new format that should be more intuitive; + * lets players on Linux/Mac choose the console color scheme (SMAPI will auto-detect it on Windows); + * and validates requirements earlier. + * Fixed custom festival maps always using spring tilesheets. + * Fixed `player_add` command not recognising return scepter. + * Fixed `player_add` command showing fish twice. + * Fixed some SMAPI logs not deleted when starting a new session. + +* For modders: + * Added support for `.json` data files in the content API (including Content Patcher). + * Added propagation for asset changes through the content API for... + * child sprites; + * dialogue; + * map tilesheets. + * Added `--mods-path` CLI command-line argument to switch between mod folders. + * All enums are now JSON-serialised by name instead of numeric value. (Previously only a few enums were serialised that way. JSON files which already have numeric enum values will still be parsed fine.) + * Fixed false compatibility error when constructing multidimensional arrays. + * Fixed `.ToSButton()` methods not being public. + * Updated compatibility list. + +* For SMAPI developers: + * Dropped support for pre-SMAPI-2.6 update checks in the web API. + _These are no longer useful, even if the player still has earlier versions of SMAPI. Older versions of SMAPI won't launch in Stardew Valley 1.3 (so they won't check for updates), and newer versions of SMAPI/mods won't work with older versions of the game._ + ## 2.6 * For players: * Updated for Stardew Valley 1.3. diff --git a/docs/technical-docs.md b/docs/technical-docs.md index d829baf9..ed45871a 100644 --- a/docs/technical-docs.md +++ b/docs/technical-docs.md @@ -138,6 +138,7 @@ change without warning. argument | purpose -------- | ------- `--no-terminal` | SMAPI won't write anything to the console window. (Messages will still be written to the log file.) +`--mods-path` | The path to search for mods, if not the standard `Mods` folder. This can be a path relative to the game folder (like `--mods-path "Mods (test)"`) or an absolute path. ### Compile flags SMAPI uses a small number of conditional compilation constants, which you can set by editing the 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) { |