diff options
Diffstat (limited to 'src')
436 files changed, 9806 insertions, 4426 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerContext.cs b/src/SMAPI.Installer/Framework/InstallerContext.cs index bb973230..a2c63dd8 100644 --- a/src/SMAPI.Installer/Framework/InstallerContext.cs +++ b/src/SMAPI.Installer/Framework/InstallerContext.cs @@ -12,7 +12,7 @@ namespace StardewModdingAPI.Installer.Framework ** Fields *********/ /// <summary>The underlying toolkit game scanner.</summary> - private readonly GameScanner GameScanner = new GameScanner(); + private readonly GameScanner GameScanner = new(); /********* @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Installer.Framework /// <summary>Get the installer's version number.</summary> public ISemanticVersion GetInstallerVersion() { - var raw = this.GetType().Assembly.GetName().Version; + var raw = this.GetType().Assembly.GetName().Version!; return new SemanticVersion(raw); } diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index b3bba883..5138173a 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; @@ -35,6 +36,7 @@ namespace StardewModdingApi.Installer /// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary> /// <param name="installDir">The folder for Stardew Valley and SMAPI.</param> /// <param name="modsDir">The folder for SMAPI mods.</param> + [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid file names.")] private IEnumerable<string> GetUninstallPaths(DirectoryInfo installDir, DirectoryInfo modsDir) { string GetInstallPath(string path) => Path.Combine(installDir.FullName, path); @@ -126,7 +128,7 @@ namespace StardewModdingApi.Installer /**** ** Get basic info & set window title ****/ - ModToolkit toolkit = new ModToolkit(); + ModToolkit toolkit = new(); var context = new InstallerContext(); Console.Title = $"SMAPI {context.GetInstallerVersion()} installer on {context.Platform} {context.PlatformName}"; Console.WriteLine(); @@ -164,7 +166,7 @@ namespace StardewModdingApi.Installer } // get game path from CLI - string gamePathArg = null; + string? gamePathArg = null; { int pathIndex = Array.LastIndexOf(args, "--game-path") + 1; if (pathIndex >= 1 && args.Length >= pathIndex) @@ -189,8 +191,8 @@ namespace StardewModdingApi.Installer ** show theme selector ****/ // get theme writers - var lightBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); - var darkBackgroundWriter = new ColorfulConsoleWriter(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); + ColorfulConsoleWriter lightBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); + ColorfulConsoleWriter darkBackgroundWriter = new(context.Platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); // print question this.PrintPlain("Which text looks more readable?"); @@ -237,7 +239,7 @@ namespace StardewModdingApi.Installer ** collect details ****/ // get game path - DirectoryInfo installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg); + DirectoryInfo? installDir = this.InteractivelyGetInstallPath(toolkit, context, gamePathArg); if (installDir == null) { this.PrintError("Failed finding your game path."); @@ -246,7 +248,7 @@ namespace StardewModdingApi.Installer } // get folders - DirectoryInfo bundleDir = new DirectoryInfo(this.BundlePath); + DirectoryInfo bundleDir = new(this.BundlePath); paths = new InstallerPaths(bundleDir, installDir); } @@ -354,8 +356,8 @@ namespace StardewModdingApi.Installer // move global save data folder (changed in 3.2) { string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); - DirectoryInfo oldDir = new DirectoryInfo(Path.Combine(dataPath, "Saves", ".smapi")); - DirectoryInfo newDir = new DirectoryInfo(Path.Combine(dataPath, ".smapi")); + DirectoryInfo oldDir = new(Path.Combine(dataPath, "Saves", ".smapi")); + DirectoryInfo newDir = new(Path.Combine(dataPath, ".smapi")); if (oldDir.Exists) { @@ -428,7 +430,7 @@ namespace StardewModdingApi.Installer } // add or replace bundled mods - DirectoryInfo bundledModsDir = new DirectoryInfo(Path.Combine(paths.BundlePath, "Mods")); + DirectoryInfo bundledModsDir = new(Path.Combine(paths.BundlePath, "Mods")); if (bundledModsDir.Exists && bundledModsDir.EnumerateDirectories().Any()) { this.PrintDebug("Adding bundled mods..."); @@ -449,8 +451,8 @@ namespace StardewModdingApi.Installer } // find target folder - ModFolder targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.OrdinalIgnoreCase) == true); - DirectoryInfo defaultTargetFolder = new DirectoryInfo(Path.Combine(paths.ModsPath, sourceMod.Directory.Name)); + ModFolder? targetMod = targetMods.FirstOrDefault(p => p.Manifest?.UniqueID?.Equals(sourceMod.Manifest.UniqueID, StringComparison.OrdinalIgnoreCase) == true); + DirectoryInfo defaultTargetFolder = new(Path.Combine(paths.ModsPath, sourceMod.Directory.Name)); DirectoryInfo targetFolder = targetMod?.Directory ?? defaultTargetFolder; this.PrintDebug(targetFolder.FullName == defaultTargetFolder.FullName ? $" adding {sourceMod.Manifest.Name}..." @@ -532,27 +534,45 @@ namespace StardewModdingApi.Installer /// <summary>Print a message without formatting.</summary> /// <param name="text">The text to print.</param> - private void PrintPlain(string text) => Console.WriteLine(text); + 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); + private void PrintDebug(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug); + } /// <summary>Print a debug message.</summary> /// <param name="text">The text to print.</param> - private void PrintInfo(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info); + private void PrintInfo(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info); + } /// <summary>Print a warning message.</summary> /// <param name="text">The text to print.</param> - private void PrintWarning(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn); + private void PrintWarning(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn); + } /// <summary>Print a warning message.</summary> /// <param name="text">The text to print.</param> - private void PrintError(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error); + private void PrintError(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error); + } /// <summary>Print a success message.</summary> /// <param name="text">The text to print.</param> - private void PrintSuccess(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success); + private void PrintSuccess(string text) + { + this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success); + } /// <summary>Interactively delete a file or folder path, and block until deletion completes.</summary> /// <param name="path">The file or folder path.</param> @@ -562,7 +582,7 @@ namespace StardewModdingApi.Installer { try { - FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : (FileSystemInfo)new FileInfo(path)); + FileUtilities.ForceDelete(Directory.Exists(path) ? new DirectoryInfo(path) : new FileInfo(path)); break; } catch (Exception ex) @@ -578,7 +598,7 @@ namespace StardewModdingApi.Installer /// <param name="source">The file or folder to copy.</param> /// <param name="targetFolder">The folder to copy into.</param> /// <param name="filter">A filter which matches directories and files to copy, or <c>null</c> to match all.</param> - private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter = null) + private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool>? filter = null) { if (filter != null && !filter(source)) return; @@ -593,8 +613,8 @@ namespace StardewModdingApi.Installer break; case DirectoryInfo sourceDir: - DirectoryInfo targetSubfolder = new DirectoryInfo(Path.Combine(targetFolder.FullName, sourceDir.Name)); - foreach (var entry in sourceDir.EnumerateFileSystemInfos()) + DirectoryInfo targetSubfolder = new(Path.Combine(targetFolder.FullName, sourceDir.Name)); + foreach (FileSystemInfo entry in sourceDir.EnumerateFileSystemInfos()) this.RecursiveCopy(entry, targetSubfolder, filter); break; @@ -608,7 +628,7 @@ namespace StardewModdingApi.Installer /// <param name="message">The message to print.</param> /// <param name="options">The allowed options (not case sensitive).</param> /// <param name="indent">The indentation to prefix to output.</param> - private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string> print = null) + private string InteractivelyChoose(string message, string[] options, string indent = "", Action<string>? print = null) { print ??= this.PrintInfo; @@ -616,8 +636,8 @@ namespace StardewModdingApi.Installer { print(indent + message); Console.Write(indent); - string input = Console.ReadLine()?.Trim().ToLowerInvariant(); - if (!options.Contains(input)) + string? input = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (input == null || !options.Contains(input)) { print($"{indent}That's not a valid option."); continue; @@ -630,7 +650,7 @@ namespace StardewModdingApi.Installer /// <param name="toolkit">The mod toolkit.</param> /// <param name="context">The installer context.</param> /// <param name="specifiedPath">The path specified as a command-line argument (if any), which should override automatic path detection.</param> - private DirectoryInfo InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string specifiedPath) + private DirectoryInfo? InteractivelyGetInstallPath(ModToolkit toolkit, InstallerContext context, string? specifiedPath) { // use specified path if (specifiedPath != null) @@ -697,7 +717,7 @@ namespace StardewModdingApi.Installer // get path from user Console.WriteLine(); this.PrintInfo($"Type the file path to the game directory (the one containing '{Constants.GameDllName}'), then press enter."); - string path = Console.ReadLine()?.Trim(); + string? path = Console.ReadLine()?.Trim(); if (string.IsNullOrWhiteSpace(path)) { this.PrintWarning("You must specify a directory path to continue."); @@ -710,14 +730,14 @@ namespace StardewModdingApi.Installer : path.Replace("\\ ", " "); // in Linux/macOS, spaces in paths may be escaped if copied from the command line if (path.StartsWith("~/")) { - string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE"); + string home = Environment.GetEnvironmentVariable("HOME") ?? Environment.GetEnvironmentVariable("USERPROFILE")!; path = Path.Combine(home, path.Substring(2)); } // get directory if (File.Exists(path)) - path = Path.GetDirectoryName(path); - DirectoryInfo directory = new DirectoryInfo(path); + path = Path.GetDirectoryName(path)!; + DirectoryInfo directory = new(path); // validate path if (!directory.Exists) @@ -763,7 +783,7 @@ namespace StardewModdingApi.Installer // game folder which contains the installer, if any { - DirectoryInfo curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; + DirectoryInfo? curPath = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory; while (curPath?.Parent != null) // must be in a folder (not at the root) { if (context.LooksLikeGameFolder(curPath)) @@ -785,7 +805,7 @@ namespace StardewModdingApi.Installer } } - /// <summary>Interactively move mods out of the appdata directory.</summary> + /// <summary>Interactively move mods out of the app data directory.</summary> /// <param name="properModsDir">The directory which should contain all mods.</param> /// <param name="packagedModsDir">The installer directory containing packaged mods.</param> private void InteractivelyRemoveAppDataMods(DirectoryInfo properModsDir, DirectoryInfo packagedModsDir) @@ -795,7 +815,7 @@ namespace StardewModdingApi.Installer // get path string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); - DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods")); + DirectoryInfo modDir = new(Path.Combine(appDataPath, "Mods")); // check if migration needed if (!modDir.Exists) @@ -808,7 +828,7 @@ namespace StardewModdingApi.Installer { // get type bool isDir = entry is DirectoryInfo; - if (!isDir && !(entry is FileInfo)) + if (!isDir && entry is not FileInfo) continue; // should never happen // delete packaged mods (newer version bundled into SMAPI) @@ -845,7 +865,7 @@ namespace StardewModdingApi.Installer /// <summary>Move a filesystem entry to a new parent directory.</summary> /// <param name="entry">The filesystem entry to move.</param> /// <param name="newPath">The destination path.</param> - /// <remarks>We can't use <see cref="FileInfo.MoveTo"/> or <see cref="DirectoryInfo.MoveTo"/>, because those don't work across partitions.</remarks> + /// <remarks>We can't use <see cref="FileInfo.MoveTo(string)"/> or <see cref="DirectoryInfo.MoveTo"/>, because those don't work across partitions.</remarks> private void Move(FileSystemInfo entry, string newPath) { // file @@ -872,15 +892,12 @@ namespace StardewModdingApi.Installer /// <param name="entry">The file or folder info.</param> private bool ShouldCopy(FileSystemInfo entry) { - switch (entry.Name) + return entry.Name switch { - case "mcs": - return false; // ignore macOS symlink - case "Mods": - return false; // Mods folder handled separately - default: - return true; - } + "mcs" => false, // ignore macOS symlink + "Mods" => false, // Mods folder handled separately + _ => true + }; } } } diff --git a/src/SMAPI.Installer/Program.cs b/src/SMAPI.Installer/Program.cs index 45cfea75..dc452a46 100644 --- a/src/SMAPI.Installer/Program.cs +++ b/src/SMAPI.Installer/Program.cs @@ -15,7 +15,7 @@ namespace StardewModdingApi.Installer *********/ /// <summary>The absolute path of the installer folder.</summary> [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute", Justification = "The assembly location is never null in this context.")] - private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + private static readonly string InstallerPath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; /// <summary>The absolute path of the folder containing the unzipped installer files.</summary> private static readonly string ExtractedBundlePath = Path.Combine(Path.GetTempPath(), $"SMAPI-installer-{Guid.NewGuid():N}"); @@ -31,7 +31,7 @@ namespace StardewModdingApi.Installer public static void Main(string[] args) { // find install bundle - FileInfo zipFile = new FileInfo(Path.Combine(Program.InstallerPath, "install.dat")); + FileInfo zipFile = new(Path.Combine(Program.InstallerPath, "install.dat")); if (!zipFile.Exists) { Console.WriteLine($"Oops! Some of the installer files are missing; try re-downloading the installer. (Missing file: {zipFile.FullName})"); @@ -40,7 +40,7 @@ namespace StardewModdingApi.Installer } // unzip bundle into temp folder - DirectoryInfo bundleDir = new DirectoryInfo(Program.ExtractedBundlePath); + DirectoryInfo bundleDir = new(Program.ExtractedBundlePath); Console.WriteLine("Extracting install files..."); ZipFile.ExtractToDirectory(zipFile.FullName, bundleDir.FullName); @@ -66,14 +66,14 @@ namespace StardewModdingApi.Installer /// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e) + private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs e) { try { - AssemblyName name = new AssemblyName(e.Name); + AssemblyName name = new(e.Name); foreach (FileInfo dll in new DirectoryInfo(Program.InternalFilesPath).EnumerateFiles("*.dll")) { - if (name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase)) + if (name.Name != null && name.Name.Equals(AssemblyName.GetAssemblyName(dll.FullName).Name, StringComparison.OrdinalIgnoreCase)) return Assembly.LoadFrom(dll.FullName); } return null; diff --git a/src/SMAPI.Installer/assets/unix-launcher.sh b/src/SMAPI.Installer/assets/unix-launcher.sh index 47937f95..ae9624e7 100644 --- a/src/SMAPI.Installer/assets/unix-launcher.sh +++ b/src/SMAPI.Installer/assets/unix-launcher.sh @@ -6,10 +6,41 @@ # move to script's directory cd "$(dirname "$0")" || exit $? -# change to true to skip opening a terminal +# Whether to avoid opening a separate terminal window, and avoid logging anything to the console. # This isn't recommended since you won't see errors, warnings, and update alerts. SKIP_TERMINAL=false +# Whether to avoid opening a separate terminal, but still send the usual log output to the console. +USE_CURRENT_SHELL=false + + +########## +## Read environment variables +########## +if [ "$SMAPI_NO_TERMINAL" == "true" ]; then + SKIP_TERMINAL=true +fi +if [ "$SMAPI_USE_CURRENT_SHELL" == "true" ]; then + USE_CURRENT_SHELL=true +fi + + +########## +## Read command-line arguments +########## +while [ "$#" -gt 0 ]; do + case "$1" in + --skip-terminal ) SKIP_TERMINAL=true; shift ;; + --use-current-shell ) USE_CURRENT_SHELL=true; shift ;; + -- ) shift; break ;; + * ) shift ;; + esac +done + +if [ "$SKIP_TERMINAL" == "true" ]; then + USE_CURRENT_SHELL=true +fi + ########## ## Open terminal if needed @@ -18,21 +49,13 @@ SKIP_TERMINAL=false # Besides letting the player see errors/warnings/alerts in the console, this is also needed because # Steam messes with the PATH. if [ "$(uname)" == "Darwin" ]; then - if [ ! -t 1 ]; then # https://stackoverflow.com/q/911168/262123 - # sanity check to make sure we don't have an infinite loop of opening windows - for argument in "$@"; do - if [ "$argument" == "--no-reopen-terminal" ]; then - SKIP_TERMINAL=true - break - fi - done - + if [ ! -t 1 ]; then # not open in Terminal (https://stackoverflow.com/q/911168/262123) # reopen in Terminal if needed # https://stackoverflow.com/a/29511052/262123 - if [ "$SKIP_TERMINAL" == "false" ]; then + if [ "$USE_CURRENT_SHELL" == "false" ]; then echo "Reopening in the Terminal app..." echo '#!/bin/sh' > /tmp/open-smapi-terminal.sh - echo "\"$0\" $@ --no-reopen-terminal" >> /tmp/open-smapi-terminal.sh + echo "\"$0\" $@ --use-current-shell" >> /tmp/open-smapi-terminal.sh chmod +x /tmp/open-smapi-terminal.sh cat /tmp/open-smapi-terminal.sh open -W -a Terminal /tmp/open-smapi-terminal.sh @@ -68,7 +91,7 @@ else export LAUNCH_FILE # run in terminal - if [ "$SKIP_TERMINAL" == "false" ]; then + if [ "$USE_CURRENT_SHELL" == "false" ]; then # select terminal (prefer xterm for best compatibility, then known supported terminals) for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator; do if command -v "$terminal" 2>/dev/null; then @@ -131,7 +154,9 @@ else fi # explicitly run without terminal - else + elif [ "$SKIP_TERMINAL" == "true" ]; then exec $LAUNCH_FILE --no-terminal "$@" + else + exec $LAUNCH_FILE "$@" fi fi diff --git a/src/SMAPI.Internal.Patching/BasePatcher.cs b/src/SMAPI.Internal.Patching/BasePatcher.cs index 87155d7f..c1936ccc 100644 --- a/src/SMAPI.Internal.Patching/BasePatcher.cs +++ b/src/SMAPI.Internal.Patching/BasePatcher.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Internal.Patching /// <param name="name">The method name.</param> /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> - protected MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null) + protected MethodInfo RequireMethod<TTarget>(string name, Type[]? parameters = null, Type[]? generics = null) { return PatchHelper.RequireMethod<TTarget>(name, parameters, generics); } @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Internal.Patching /// <param name="priority">The patch priority to apply, usually specified using Harmony's <see cref="Priority"/> enum, or <c>null</c> to keep the default value.</param> protected HarmonyMethod GetHarmonyMethod(string name, int? priority = null) { - var method = new HarmonyMethod( + HarmonyMethod method = new( AccessTools.Method(this.GetType(), name) ?? throw new InvalidOperationException($"Can't find patcher method {PatchHelper.GetMethodString(this.GetType(), name)}.") ); diff --git a/src/SMAPI.Internal.Patching/HarmonyPatcher.cs b/src/SMAPI.Internal.Patching/HarmonyPatcher.cs index c07e3b41..6f30c241 100644 --- a/src/SMAPI.Internal.Patching/HarmonyPatcher.cs +++ b/src/SMAPI.Internal.Patching/HarmonyPatcher.cs @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Internal.Patching /// <param name="patchers">The patchers to apply.</param> public static Harmony Apply(string id, IMonitor monitor, params IPatcher[] patchers) { - Harmony harmony = new Harmony(id); + Harmony harmony = new(id); foreach (IPatcher patcher in patchers) { diff --git a/src/SMAPI.Internal.Patching/PatchHelper.cs b/src/SMAPI.Internal.Patching/PatchHelper.cs index fc79ddf2..edd8ef57 100644 --- a/src/SMAPI.Internal.Patching/PatchHelper.cs +++ b/src/SMAPI.Internal.Patching/PatchHelper.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Internal.Patching /// <typeparam name="TTarget">The type containing the method.</typeparam> /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> /// <exception cref="InvalidOperationException">The type has no matching constructor.</exception> - public static ConstructorInfo RequireConstructor<TTarget>(Type[] parameters = null) + public static ConstructorInfo RequireConstructor<TTarget>(Type[]? parameters = null) { return AccessTools.Constructor(typeof(TTarget), parameters) @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Internal.Patching /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> /// <exception cref="InvalidOperationException">The type has no matching method.</exception> - public static MethodInfo RequireMethod<TTarget>(string name, Type[] parameters = null, Type[] generics = null) + public static MethodInfo RequireMethod<TTarget>(string name, Type[]? parameters = null, Type[]? generics = null) { return AccessTools.Method(typeof(TTarget), name, parameters, generics) @@ -41,9 +41,9 @@ namespace StardewModdingAPI.Internal.Patching /// <param name="name">The method name, or <c>null</c> for a constructor.</param> /// <param name="parameters">The method parameter types, or <c>null</c> if it's not overloaded.</param> /// <param name="generics">The method generic types, or <c>null</c> if it's not generic.</param> - public static string GetMethodString(Type type, string name, Type[] parameters = null, Type[] generics = null) + public static string GetMethodString(Type type, string? name, Type[]? parameters = null, Type[]? generics = null) { - StringBuilder str = new StringBuilder(); + StringBuilder str = new(); // type str.Append(type.FullName); diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs index 001840bf..4e5850ea 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs @@ -6,10 +6,26 @@ namespace StardewModdingAPI.Internal.ConsoleWriting /// <summary>The console color scheme options.</summary> internal class ColorSchemeConfig { + /********* + ** Accessors + *********/ /// <summary>The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</summary> - public MonitorColorScheme UseScheme { get; set; } + public MonitorColorScheme UseScheme { get; } /// <summary>The available console color schemes.</summary> - public IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> Schemes { get; set; } + public IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> Schemes { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="useScheme">The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</param> + /// <param name="schemes">The available console color schemes.</param> + public ColorSchemeConfig(MonitorColorScheme useScheme, IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> schemes) + { + this.UseScheme = useScheme; + this.Schemes = schemes; + } } } diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs index bfe155e0..78db0d65 100644 --- a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Internal.ConsoleWriting @@ -11,10 +12,11 @@ namespace StardewModdingAPI.Internal.ConsoleWriting ** Fields *********/ /// <summary>The console text color for each log level.</summary> - private readonly IDictionary<ConsoleLogLevel, ConsoleColor> Colors; + private readonly IDictionary<ConsoleLogLevel, ConsoleColor>? Colors; /// <summary>Whether the current console supports color formatting.</summary> - private readonly bool SupportsColor; + [MemberNotNullWhen(true, nameof(ColorfulConsoleWriter.Colors))] + private bool SupportsColor { get; } /********* @@ -72,10 +74,9 @@ namespace StardewModdingAPI.Internal.ConsoleWriting /// <remarks>The colors here should be kept in sync with the SMAPI config file.</remarks> public static ColorSchemeConfig GetDefaultColorSchemeConfig(MonitorColorScheme useScheme) { - return new ColorSchemeConfig - { - UseScheme = useScheme, - Schemes = new Dictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> + return new ColorSchemeConfig( + useScheme: useScheme, + schemes: new Dictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> { [MonitorColorScheme.DarkBackground] = new Dictionary<ConsoleLogLevel, ConsoleColor> { @@ -98,7 +99,7 @@ namespace StardewModdingAPI.Internal.ConsoleWriting [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen } } - }; + ); } @@ -134,7 +135,7 @@ namespace StardewModdingAPI.Internal.ConsoleWriting } // get colors for scheme - return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary<ConsoleLogLevel, ConsoleColor> scheme) + return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary<ConsoleLogLevel, ConsoleColor>? scheme) ? scheme : throw new NotSupportedException($"Unknown color scheme '{schemeID}'."); } diff --git a/src/SMAPI.Internal/ExceptionHelper.cs b/src/SMAPI.Internal/ExceptionHelper.cs index 05b96c2e..7edc0f62 100644 --- a/src/SMAPI.Internal/ExceptionHelper.cs +++ b/src/SMAPI.Internal/ExceptionHelper.cs @@ -12,7 +12,7 @@ namespace StardewModdingAPI.Internal *********/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> /// <param name="exception">The error to summarize.</param> - public static string GetLogSummary(this Exception exception) + public static string GetLogSummary(this Exception? exception) { try { @@ -25,7 +25,7 @@ namespace StardewModdingAPI.Internal case ReflectionTypeLoadException ex: string summary = ex.ToString(); - foreach (Exception childEx in ex.LoaderExceptions ?? new Exception[0]) + foreach (Exception? childEx in ex.LoaderExceptions) summary += $"\n\n{childEx?.GetLogSummary()}"; message = summary; break; @@ -43,15 +43,6 @@ namespace StardewModdingAPI.Internal } } - /// <summary>Get the lowest exception in an exception stack.</summary> - /// <param name="exception">The exception from which to search.</param> - public static Exception GetInnermostException(this Exception exception) - { - while (exception.InnerException != null) - exception = exception.InnerException; - return exception; - } - /// <summary>Simplify common patterns in exception log messages that don't convey useful info.</summary> /// <param name="message">The log message to simplify.</param> public static string SimplifyExtensionMessage(string message) diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs index 896c2cb8..845149bd 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs @@ -1,6 +1,7 @@ // <generated /> -using Microsoft.CodeAnalysis; +// ReSharper disable All -- generated code using System; +using Microsoft.CodeAnalysis; namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs index 0247288e..4bda70ff 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs @@ -1,12 +1,14 @@ // <generated /> -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.Diagnostics; -using Microsoft.CodeAnalysis.Text; +// ReSharper disable All -- generated code + using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { @@ -51,17 +53,17 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents) { var projects = new HashSet<Project>(); - foreach (var document in documents) + foreach (Document document in documents) { projects.Add(document.Project); } var diagnostics = new List<Diagnostic>(); - foreach (var project in projects) + foreach (Project project in projects) { - var compilationWithAnalyzers = project.GetCompilationAsync().Result.WithAnalyzers(ImmutableArray.Create(analyzer)); + CompilationWithAnalyzers compilationWithAnalyzers = project.GetCompilationAsync().Result!.WithAnalyzers(ImmutableArray.Create(analyzer)); var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result; - foreach (var diag in diags) + foreach (Diagnostic diag in diags) { if (diag.Location == Location.None || diag.Location.IsInMetadata) { @@ -71,8 +73,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { for (int i = 0; i < documents.Length; i++) { - var document = documents[i]; - var tree = document.GetSyntaxTreeAsync().Result; + Document document = documents[i]; + SyntaxTree? tree = document.GetSyntaxTreeAsync().Result; if (tree == diag.Location.SourceTree) { diagnostics.Add(diag); @@ -113,7 +115,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework throw new ArgumentException("Unsupported Language"); } - var project = CreateProject(sources, language); + Project project = CreateProject(sources, language); var documents = project.Documents.ToArray(); if (sources.Length != documents.Length) @@ -125,17 +127,6 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework } /// <summary> - /// Create a Document from a string through creating a project that contains it. - /// </summary> - /// <param name="source">Classes in the form of a string</param> - /// <param name="language">The language the source code is in</param> - /// <returns>A Document created from the source string</returns> - protected static Document CreateDocument(string source, string language = LanguageNames.CSharp) - { - return CreateProject(new[] { source }, language).Documents.First(); - } - - /// <summary> /// Create a project using the inputted strings as sources. /// </summary> /// <param name="sources">Classes in the form of strings</param> @@ -146,9 +137,9 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework string fileNamePrefix = DefaultFilePathPrefix; string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt; - var projectId = ProjectId.CreateNewId(debugName: TestProjectName); + ProjectId projectId = ProjectId.CreateNewId(debugName: TestProjectName); - var solution = new AdhocWorkspace() + Solution solution = new AdhocWorkspace() .CurrentSolution .AddProject(projectId, TestProjectName, TestProjectName, language) .AddMetadataReference(projectId, DiagnosticVerifier.SelfReference) @@ -158,14 +149,14 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework .AddMetadataReference(projectId, CodeAnalysisReference); int count = 0; - foreach (var source in sources) + foreach (string source in sources) { - var newFileName = fileNamePrefix + count + "." + fileExt; - var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); + string newFileName = fileNamePrefix + count + "." + fileExt; + DocumentId documentId = DocumentId.CreateNewId(projectId, debugName: newFileName); solution = solution.AddDocument(documentId, newFileName, SourceText.From(source)); count++; } - return solution.GetProject(projectId); + return solution.GetProject(projectId)!; } #endregion } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs index edaaabd4..efe69e4a 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs @@ -1,4 +1,6 @@ // <generated /> +// ReSharper disable All -- generated code + using System.Collections.Generic; using System.Linq; using System.Text; @@ -17,18 +19,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework /// <summary> /// Get the CSharp analyzer being tested - to be implemented in non-abstract class /// </summary> - protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer() - { - return null; - } - - /// <summary> - /// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class - /// </summary> - protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer() - { - return null; - } + protected abstract DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer(); #endregion #region Verifier wrappers @@ -41,18 +32,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework /// <param name="expected"> DiagnosticResults that should appear after the analyzer is run on the source</param> protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected) { - VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected); - } - - /// <summary> - /// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source - /// Note: input a DiagnosticResult for each Diagnostic expected - /// </summary> - /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param> - /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param> - protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected) - { - VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected); + this.VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, this.GetCSharpDiagnosticAnalyzer(), expected); } /// <summary> @@ -65,8 +45,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param> private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected) { - var diagnostics = GetSortedDiagnostics(sources, language, analyzer); - VerifyDiagnosticResults(diagnostics, analyzer, expected); + var diagnostics = DiagnosticVerifier.GetSortedDiagnostics(sources, language, analyzer); + DiagnosticVerifier.VerifyDiagnosticResults(diagnostics, analyzer, expected); } #endregion @@ -86,7 +66,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework if (expectedCount != actualCount) { - string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; + string diagnosticsOutput = actualResults.Any() ? DiagnosticVerifier.FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE."; Assert.IsTrue(false, string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput)); @@ -103,12 +83,12 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { Assert.IsTrue(false, string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}", - FormatDiagnostics(analyzer, actual))); + DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } } else { - VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First()); + DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First()); var additionalLocations = actual.AdditionalLocations.ToArray(); if (additionalLocations.Length != expected.Locations.Length - 1) @@ -116,12 +96,12 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework Assert.IsTrue(false, string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n", expected.Locations.Length - 1, additionalLocations.Length, - FormatDiagnostics(analyzer, actual))); + DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } for (int j = 0; j < additionalLocations.Length; ++j) { - VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); + DiagnosticVerifier.VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]); } } @@ -129,21 +109,21 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { Assert.IsTrue(false, string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Id, actual.Id, FormatDiagnostics(analyzer, actual))); + expected.Id, actual.Id, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } if (actual.Severity != expected.Severity) { Assert.IsTrue(false, string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Severity, actual.Severity, FormatDiagnostics(analyzer, actual))); + expected.Severity, actual.Severity, DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } if (actual.GetMessage() != expected.Message) { Assert.IsTrue(false, string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Message, actual.GetMessage(), FormatDiagnostics(analyzer, actual))); + expected.Message, actual.GetMessage(), DiagnosticVerifier.FormatDiagnostics(analyzer, actual))); } } } @@ -161,7 +141,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework Assert.IsTrue(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")), string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Path, actualSpan.Path, FormatDiagnostics(analyzer, diagnostic))); + expected.Path, actualSpan.Path, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); var actualLinePosition = actualSpan.StartLinePosition; @@ -172,7 +152,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { Assert.IsTrue(false, string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Line, actualLinePosition.Line + 1, FormatDiagnostics(analyzer, diagnostic))); + expected.Line, actualLinePosition.Line + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); } } @@ -183,7 +163,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework { Assert.IsTrue(false, string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n", - expected.Column, actualLinePosition.Character + 1, FormatDiagnostics(analyzer, diagnostic))); + expected.Column, actualLinePosition.Character + 1, DiagnosticVerifier.FormatDiagnostics(analyzer, diagnostic))); } } } @@ -201,7 +181,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework var builder = new StringBuilder(); for (int i = 0; i < diagnostics.Length; ++i) { - builder.AppendLine("// " + diagnostics[i].ToString()); + builder.AppendLine("// " + diagnostics[i]); var analyzerType = analyzer.GetType(); var rules = analyzer.SupportedDiagnostics; @@ -220,11 +200,10 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework Assert.IsTrue(location.IsInSource, $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n"); - string resultMethodName = diagnostics[i].Location.SourceTree.FilePath.EndsWith(".cs") ? "GetCSharpResultAt" : "GetBasicResultAt"; var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition; builder.AppendFormat("{0}({1}, {2}, {3}.{4})", - resultMethodName, + "GetCSharpResultAt", linePosition.Line + 1, linePosition.Character + 1, analyzerType.Name, diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs index d160610e..8bedd583 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetCollection.cs @@ -1,10 +1,8 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -using System.Collections; -using System.Collections.Generic; using System.Collections.ObjectModel; namespace Netcode { /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetCollection</c> for unit testing.</summary> - public class NetCollection<T> : Collection<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { } + public class NetCollection<T> : Collection<T> { } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs index 140c6f59..8f6b8987 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs @@ -7,10 +7,13 @@ namespace Netcode public class NetFieldBase<T, TSelf> where TSelf : NetFieldBase<T, TSelf> { /// <summary>The synchronised value.</summary> - public T Value { get; set; } + public T? Value { get; set; } /// <summary>Implicitly convert a net field to the its type.</summary> /// <param name="field">The field to convert.</param> - public static implicit operator T(NetFieldBase<T, TSelf> field) => field.Value; + public static implicit operator T?(NetFieldBase<T, TSelf> field) + { + return field.Value; + } } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs index 1699f71c..33e616fb 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetList.cs @@ -1,9 +1,8 @@ // ReSharper disable CheckNamespace -- matches Stardew Valley's code -using System.Collections; using System.Collections.Generic; namespace Netcode { /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary> - public class NetList<T> : List<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { } + public class NetList<T> : List<T> { } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs index 13fab069..dbd05792 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs @@ -1,5 +1,5 @@ // ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code -#pragma warning disable 649 // (never assigned) -- only used to test type conversions +// ReSharper disable UnusedMember.Global -- used dynamically for unit tests using System.Collections.Generic; namespace StardewValley @@ -8,6 +8,6 @@ namespace StardewValley internal class Farmer { /// <summary>A sample field which should be replaced with a different property.</summary> - public readonly IDictionary<string, int[]> friendships; + public readonly IDictionary<string, int[]> friendships = new Dictionary<string, int[]>(); } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs index 1b6317c1..d50deb72 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs @@ -1,4 +1,5 @@ // ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code +// ReSharper disable UnusedMember.Global -- used dynamically for unit tests using Netcode; namespace StardewValley @@ -7,27 +8,27 @@ namespace StardewValley public class Item { /// <summary>A net int field with an equivalent non-net <c>Category</c> property.</summary> - public readonly NetInt category = new NetInt { Value = 42 }; + public readonly NetInt category = new() { Value = 42 }; /// <summary>A generic net int field with no equivalent non-net property.</summary> - public readonly NetInt netIntField = new NetInt { Value = 42 }; + public readonly NetInt netIntField = new() { Value = 42 }; /// <summary>A generic net ref field with no equivalent non-net property.</summary> - public readonly NetRef<object> netRefField = new NetRef<object>(); + public readonly NetRef<object> netRefField = new(); /// <summary>A generic net int property with no equivalent non-net property.</summary> - public NetInt netIntProperty = new NetInt { Value = 42 }; + public NetInt netIntProperty = new() { Value = 42 }; /// <summary>A generic net ref property with no equivalent non-net property.</summary> - public NetRef<object> netRefProperty { get; } = new NetRef<object>(); + public NetRef<object> netRefProperty { get; } = new(); /// <summary>A sample net list.</summary> - public readonly NetList<int> netList = new NetList<int>(); + public readonly NetList<int> netList = new(); /// <summary>A sample net object list.</summary> - public readonly NetObjectList<int> netObjectList = new NetObjectList<int>(); + public readonly NetObjectList<int> netObjectList = new(); /// <summary>A sample net collection.</summary> - public readonly NetCollection<int> netCollection = new NetCollection<int>(); + public readonly NetCollection<int> netCollection = new(); } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs index 3dd66a6d..151010a7 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs @@ -7,6 +7,6 @@ namespace StardewValley public class Object : Item { /// <summary>A net int field with an equivalent non-net property.</summary> - public NetInt type = new NetInt { Value = 42 }; + public NetInt type = new() { Value = 42 }; } } diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs index 89bd1be5..a6fa5633 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs @@ -87,13 +87,13 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests [TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")] [TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type [TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")] - [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetInt", "int")] // ↓ explicit conversion to invalid type + [TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetFieldBase", "int")] // ↓ explicit conversion to invalid type [TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "NetRef", "object")] public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType) { // arrange string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new DiagnosticResult + DiagnosticResult expected = new() { Id = "AvoidImplicitNetFieldCast", Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.", @@ -135,7 +135,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests { // arrange string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new DiagnosticResult + DiagnosticResult expected = new() { Id = "AvoidNetField", Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/package/avoid-net-field for details.", diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs index 12641e1a..76607b8e 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs @@ -64,7 +64,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests { // arrange string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText); - DiagnosticResult expected = new DiagnosticResult + DiagnosticResult expected = new() { Id = "AvoidObsoleteField", Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/package/avoid-obsolete-field for details.", diff --git a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..9a46676d --- /dev/null +++ b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerReleases.Shipped.md @@ -0,0 +1,7 @@ +## Release 2.1.0 +### New Rules +Rule ID | Category | Severity | Notes +------------------------- | ------------------ | -------- | ------------------------------------------------------------ +AvoidImplicitNetFieldCast | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). +AvoidNetField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). +AvoidObsoleteField | SMAPI.CommonErrors | Warning | See [documentation](https://smapi.io/package/code-warnings). diff --git a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs index 68b5001e..2e34cf71 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/AnalyzerUtilities.cs @@ -40,8 +40,8 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer // invalid fromExpression = null; - fromType = default(TypeInfo); - toType = default(TypeInfo); + fromType = default; + toType = default; return false; } @@ -64,7 +64,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer } // conditional access - if (node is ConditionalAccessExpressionSyntax conditionalAccess && conditionalAccess.WhenNotNull is MemberBindingExpressionSyntax conditionalBinding) + if (node is ConditionalAccessExpressionSyntax { WhenNotNull: MemberBindingExpressionSyntax conditionalBinding } conditionalAccess) { declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type; memberType = semanticModel.GetTypeInfo(node); @@ -74,7 +74,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer // invalid declaringType = null; - memberType = default(TypeInfo); + memberType = default; memberName = null; return false; } diff --git a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs index e03c72de..553aae99 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs @@ -132,7 +132,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer }; /// <summary>The diagnostic info for an implicit net field cast.</summary> - private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new DiagnosticDescriptor( + private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new( id: "AvoidImplicitNetFieldCast", title: "Netcode types shouldn't be implicitly converted", messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/package/avoid-implicit-net-field-cast for details.", @@ -143,7 +143,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer ); /// <summary>The diagnostic info for an avoidable net field access.</summary> - private readonly DiagnosticDescriptor AvoidNetFieldRule = new DiagnosticDescriptor( + private readonly DiagnosticDescriptor AvoidNetFieldRule = new( id: "AvoidNetField", title: "Avoid Netcode types when possible", messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.", @@ -227,10 +227,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer // warn: implicit conversion if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType)) - { context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType)); - return; - } }); } diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs index 722d5227..ba089513 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs @@ -24,7 +24,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer /// <summary>Describes the diagnostic rule covered by the analyzer.</summary> private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor> { - ["AvoidObsoleteField"] = new DiagnosticDescriptor( + ["AvoidObsoleteField"] = new( id: "AvoidObsoleteField", title: "Reference to obsolete field", messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/package/avoid-obsolete-field for details.", @@ -77,7 +77,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer try { // get reference info - if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)) + if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out _, out string memberName)) return; // suggest replacement diff --git a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj index 3fadc37a..7ac3277e 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj @@ -13,6 +13,10 @@ </ItemGroup> <ItemGroup> + <AdditionalFiles Include="AnalyzerReleases.Shipped.md" /> + </ItemGroup> + + <ItemGroup> <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> </ItemGroup> </Project> diff --git a/src/SMAPI.ModBuildConfig/DeployModTask.cs b/src/SMAPI.ModBuildConfig/DeployModTask.cs index 140933bd..c7026ee1 100644 --- a/src/SMAPI.ModBuildConfig/DeployModTask.cs +++ b/src/SMAPI.ModBuildConfig/DeployModTask.cs @@ -88,7 +88,7 @@ namespace StardewModdingAPI.ModBuildConfig Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray(); // get mod info - ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip); + ModFileManager package = new(this.ProjectDir, this.TargetDir, ignoreFilePaths, ignoreFilePatterns, bundleAssemblyTypes, this.ModDllName, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip); // deploy mod files if (this.EnableModDeploy) @@ -246,7 +246,7 @@ namespace StardewModdingAPI.ModBuildConfig // create zip file Directory.CreateDirectory(Path.GetDirectoryName(zipPath)!); using Stream zipStream = new FileStream(zipPath, FileMode.Create, FileAccess.Write); - using ZipArchive archive = new ZipArchive(zipStream, ZipArchiveMode.Create); + using ZipArchive archive = new(zipStream, ZipArchiveMode.Create); foreach (var fileEntry in files) { diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index ad4ffdf9..80955f67 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -136,7 +136,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework // project manifest bool hasProjectManifest = false; { - FileInfo manifest = new FileInfo(Path.Combine(projectDir, this.ManifestFileName)); + FileInfo manifest = new(Path.Combine(projectDir, this.ManifestFileName)); if (manifest.Exists) { yield return Tuple.Create(this.ManifestFileName, manifest); @@ -146,7 +146,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework // project i18n files bool hasProjectTranslations = false; - DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n")); + DirectoryInfo translationsFolder = new(Path.Combine(projectDir, "i18n")); if (translationsFolder.Exists) { foreach (FileInfo file in translationsFolder.EnumerateFiles()) @@ -156,7 +156,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework // project assets folder bool hasAssetsFolder = false; - DirectoryInfo assetsFolder = new DirectoryInfo(Path.Combine(projectDir, "assets")); + DirectoryInfo assetsFolder = new(Path.Combine(projectDir, "assets")); if (assetsFolder.Exists) { foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) @@ -168,7 +168,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework } // build output - DirectoryInfo buildFolder = new DirectoryInfo(targetDir); + DirectoryInfo buildFolder = new(targetDir); foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) { // get path info diff --git a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index 0bc8c45e..c5790186 100644 --- a/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -5,11 +5,12 @@ <TargetFramework>netstandard2.0</TargetFramework> <LangVersion>latest</LangVersion> <GeneratePackageOnBuild>true</GeneratePackageOnBuild> + <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking> <!--NuGet package--> <PackageId>Pathoschild.Stardew.ModBuildConfig</PackageId> <Title>Build package for SMAPI mods</Title> - <Version>4.0.0</Version> + <Version>4.0.1</Version> <Authors>Pathoschild</Authors> <Description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.13.0 or later.</Description> <PackageLicenseExpression>MIT</PackageLicenseExpression> @@ -24,6 +25,12 @@ <ItemGroup> <PackageReference Include="Microsoft.Build.Utilities.Core" Version="16.10" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + + <!-- + This is imported through Microsoft.Build.Utilities.Core. When installed by a mod, NuGet + otherwise imports version 4.3.0 instead of 5.0.0, which conflicts with SMAPI's version. + --> + <PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs index 7e157c38..66f2f105 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ArgumentParser.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands @@ -52,7 +53,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands /// <param name="value">The parsed value.</param> /// <param name="required">Whether to show an error if the argument is missing.</param> /// <param name="oneOf">Require that the argument match one of the given values (case-insensitive).</param> - public bool TryGet(int index, string name, out string value, bool required = true, string[] oneOf = null) + public bool TryGet(int index, string name, [NotNullWhen(true)] out string? value, bool required = true, string[]? oneOf = null) { value = null; @@ -86,7 +87,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands value = 0; // get argument - if (!this.TryGet(index, name, out string raw, required)) + if (!this.TryGet(index, name, out string? raw, required)) return false; // parse diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs index 01cab92e..44b7824e 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/ConsoleCommand.cs @@ -100,7 +100,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands List<string[]> lines = new List<string[]>(rows.Length + 2) { header, - header.Select((value, i) => "".PadRight(widths[i], '-')).ToArray() + header.Select((_, i) => "".PadRight(widths[i], '-')).ToArray() }; lines.AddRange(rows); diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs index 957b0e75..f2194cff 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ApplySaveFixCommand.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which runs one of the game's save migrations.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ApplySaveFixCommand : ConsoleCommand { /********* @@ -21,7 +23,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other public override void Handle(IMonitor monitor, string command, ArgumentParser args) { // get fix ID - if (!args.TryGet(0, "fix_id", out string rawFixId, required: false)) + if (!args.TryGet(0, "fix_id", out string? rawFixId, required: false)) { monitor.Log("Invalid usage. Type 'help apply_save_fix' for details.", LogLevel.Error); return; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs index 1955c14e..cf1dcbce 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/DebugCommand.cs @@ -1,8 +1,10 @@ -using StardewValley; +using System.Diagnostics.CodeAnalysis; +using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which sends a debug command to the game.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class DebugCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs index 9beedb96..159d7c4a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/RegenerateBundles.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using Netcode; @@ -9,6 +10,7 @@ using StardewValley.Network; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which regenerates the game's bundles.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class RegenerateBundlesCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs index 27f6ce53..a233d588 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowDataFilesCommand.cs @@ -1,8 +1,10 @@ -using System.Diagnostics; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which shows the data files.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ShowDataFilesCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs index 71093184..745b821b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/ShowGameFilesCommand.cs @@ -1,8 +1,10 @@ -using System.Diagnostics; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which shows the game files.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ShowGameFilesCommand : ConsoleCommand { /********* @@ -18,8 +20,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other /// <param name="args">The command arguments.</param> public override void Handle(IMonitor monitor, string command, ArgumentParser args) { - Process.Start(Constants.ExecutionPath); - monitor.Log($"OK, opening {Constants.ExecutionPath}.", LogLevel.Info); + Process.Start(Constants.GamePath); + monitor.Log($"OK, opening {Constants.GamePath}.", LogLevel.Info); } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs index 46583dc3..8bf9f5db 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Other/TestInputCommand.cs @@ -1,8 +1,10 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other { /// <summary>A command which logs the keys being pressed for 30 seconds once enabled.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class TestInputCommand : ConsoleCommand { /********* @@ -37,9 +39,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other public override void OnUpdated(IMonitor monitor) { // handle expiry - if (this.ExpiryTicks == null) - return; - if (this.ExpiryTicks <= DateTime.UtcNow.Ticks) + if (this.ExpiryTicks != null && this.ExpiryTicks <= DateTime.UtcNow.Ticks) { monitor.Log("No longer logging input.", LogLevel.Info); this.ExpiryTicks = null; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index 0e8f7517..74d3d9df 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player ** Fields *********/ /// <summary>Provides methods for searching and constructing items.</summary> - private readonly ItemRepository Items = new ItemRepository(); + private readonly ItemRepository Items = new(); /// <summary>The type names recognized by this command.</summary> private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray(); @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player } // read arguments - if (!args.TryGet(0, "item type", out string type, oneOf: this.ValidTypes)) + if (!args.TryGet(0, "item type", out string? type, oneOf: this.ValidTypes)) return; if (!args.TryGetInt(2, "count", out int count, min: 1, required: false)) count = 1; @@ -48,7 +48,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player quality = Object.lowQuality; // find matching item - SearchableItem match = Enum.TryParse(type, true, out ItemType itemType) + SearchableItem? match = Enum.TryParse(type, true, out ItemType itemType) ? this.FindItemByID(monitor, args, itemType) : this.FindItemByName(monitor, args); if (match == null) @@ -76,14 +76,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <param name="monitor">Writes messages to the console and log file.</param> /// <param name="args">The command arguments.</param> /// <param name="type">The item type.</param> - private SearchableItem FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type) + private SearchableItem? FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type) { // read arguments if (!args.TryGetInt(1, "item ID", out int id, min: 0)) return null; // find matching item - SearchableItem item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id); + SearchableItem? item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id); if (item == null) monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error); return item; @@ -92,10 +92,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Get a matching item by its name.</summary> /// <param name="monitor">Writes messages to the console and log file.</param> /// <param name="args">The command arguments.</param> - private SearchableItem FindItemByName(IMonitor monitor, ArgumentParser args) + private SearchableItem? FindItemByName(IMonitor monitor, ArgumentParser args) { // read arguments - if (!args.TryGet(1, "item name", out string name)) + if (!args.TryGet(1, "item name", out string? name)) return null; // find matching items diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs index 1f12e5f9..ef35ad19 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemTypesCommand.cs @@ -1,16 +1,18 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which list item types.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ListItemTypesCommand : ConsoleCommand { /********* ** Fields *********/ /// <summary>Provides methods for searching and constructing items.</summary> - private readonly ItemRepository Items = new ItemRepository(); + private readonly ItemRepository Items = new(); /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 67569298..73d5b79d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -1,18 +1,20 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which list items available to spawn.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ListItemsCommand : ConsoleCommand { /********* ** Fields *********/ /// <summary>Provides methods for searching and constructing items.</summary> - private readonly ItemRepository Items = new ItemRepository(); + private readonly ItemRepository Items = new(); /********* @@ -59,15 +61,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player private IEnumerable<SearchableItem> GetItems(string[] searchWords) { // normalize search term - searchWords = searchWords?.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); - if (searchWords?.Any() == false) - searchWords = null; + searchWords = searchWords.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); + bool getAll = !searchWords.Any(); // find matches return ( from item in this.Items.GetAll() let term = $"{item.ID}|{item.Type}|{item.Name}|{item.DisplayName}" - where searchWords == null || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) + where getAll || searchWords.All(word => term.IndexOf(word, StringComparison.CurrentCultureIgnoreCase) != -1) select item ); } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs index 7b7cbf83..12a51bc9 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetColorCommand.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using Microsoft.Xna.Framework; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the color of a player feature.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetColorCommand : ConsoleCommand { /********* @@ -20,9 +22,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player public override void Handle(IMonitor monitor, string command, ArgumentParser args) { // parse arguments - if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "eyes", "pants" })) + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "eyes", "pants" })) return; - if (!args.TryGet(1, "color", out string rawColor)) + if (!args.TryGet(1, "color", out string? rawColor)) return; // parse color diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs index 6b3b27cd..b2035d42 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetFarmTypeCommand.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Text; @@ -10,6 +11,7 @@ using StardewValley.GameData; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which changes the player's farm type.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetFarmTypeCommand : ConsoleCommand { /********* @@ -33,7 +35,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player } // parse arguments - if (!args.TryGet(0, "farm type", out string farmType)) + if (!args.TryGet(0, "farm type", out string? farmType)) return; bool isVanillaId = int.TryParse(farmType, out int vanillaId) && vanillaId is (>= 0 and < Farm.layout_max); @@ -108,7 +110,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player return; } - if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType customFarmType)) + if (!this.GetCustomFarmTypes().TryGetValue(id, out ModFarmType? customFarmType)) { monitor.Log($"Invalid farm type '{id}'. Enter `help set_farm_type` for more info.", LogLevel.Error); return; @@ -121,7 +123,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Change the farm type.</summary> /// <param name="type">The farm type ID.</param> /// <param name="customFarmData">The custom farm type data, if applicable.</param> - private void SetFarmType(int type, ModFarmType customFarmData) + private void SetFarmType(int type, ModFarmType? customFarmData) { // set flags Game1.whichFarm = type; @@ -131,9 +133,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player Farm farm = Game1.getFarm(); farm.mapPath.Value = $@"Maps\{Farm.getMapNameFromTypeInt(Game1.whichFarm)}"; farm.reloadMap(); + farm.updateWarps(); // clear spouse area cache to avoid errors - FieldInfo cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + FieldInfo? cacheField = farm.GetType().GetField("_baseSpouseAreaTiles", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (cacheField == null) throw new InvalidOperationException("Failed to access '_baseSpouseAreaTiles' field to clear spouse area cache."); if (cacheField.GetValue(farm) is not IDictionary cache) @@ -161,7 +164,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <param name="type">The farm type.</param> private string GetVanillaName(int type) { - string translationKey = type switch + string? translationKey = type switch { Farm.default_layout => "Character_FarmStandard", Farm.riverlands_layout => "Character_FarmFishing", @@ -194,7 +197,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player ****/ /// <summary>Get the display name for a custom farm type.</summary> /// <param name="farmType">The custom farm type.</param> - private string GetCustomName(ModFarmType farmType) + private string? GetCustomName(ModFarmType? farmType) { if (string.IsNullOrWhiteSpace(farmType?.TooltipStringPath)) return farmType?.ID; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs index f27b336f..f169159f 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetHealthCommand.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current health.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetHealthCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs index df90adf2..1065bd21 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetImmunityCommand.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current immunity.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetImmunityCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs index a5f7f444..c2c4931d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxHealthCommand.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's maximum health.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetMaxHealthCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs index e3c2f011..8c794e75 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMaxStaminaCommand.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's maximum stamina.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetMaxStaminaCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index 787ce920..3afcc62b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current money.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetMoneyCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs index 4911ad1c..37c02ed0 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetNameCommand.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's name.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetNameCommand : ConsoleCommand { /********* @@ -19,9 +21,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player public override void Handle(IMonitor monitor, string command, ArgumentParser args) { // parse arguments - if (!args.TryGet(0, "target", out string target, oneOf: new[] { "player", "farm" })) + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "player", "farm" })) return; - args.TryGet(1, "name", out string name, required: false); + args.TryGet(1, "name", out string? name, required: false); // handle switch (target) diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs index c78378ef..24718ace 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStaminaCommand.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits the player's current stamina.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetStaminaCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs index 98f6c330..8193ff27 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetStyleCommand.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player { /// <summary>A command which edits a player style.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetStyleCommand : ConsoleCommand { /********* @@ -19,7 +21,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player public override void Handle(IMonitor monitor, string command, ArgumentParser args) { // parse arguments - if (!args.TryGet(0, "target", out string target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) + if (!args.TryGet(0, "target", out string? target, oneOf: new[] { "hair", "shirt", "acc", "skin", "shoe", "swim", "gender" })) return; if (!args.TryGetInt(1, "style ID", out int styleID)) return; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs index ceaeb278..4905b89a 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/ClearCommand.cs @@ -1,5 +1,7 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; +using Microsoft.Xna.Framework; using StardewValley; using StardewValley.Locations; using StardewValley.Objects; @@ -9,6 +11,7 @@ using SObject = StardewValley.Object; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which clears in-game objects.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class ClearCommand : ConsoleCommand { /********* @@ -49,13 +52,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World } // parse arguments - if (!args.TryGet(0, "location", out string locationName, required: true)) + if (!args.TryGet(0, "location", out string? locationName, required: true)) return; - if (!args.TryGet(1, "object type", out string type, required: true, oneOf: this.ValidTypes)) + if (!args.TryGet(1, "object type", out string? type, required: true, oneOf: this.ValidTypes)) return; // get target location - GameLocation location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase)); + GameLocation? location = Game1.locations.FirstOrDefault(p => p.Name != null && p.Name.Equals(locationName, StringComparison.OrdinalIgnoreCase)); if (location == null && locationName == "current") location = Game1.currentLocation; if (location == null) @@ -92,11 +95,10 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World removed += this.RemoveObjects(location, obj => - !(obj is Chest) + obj is not Chest && ( - obj.Name == "Weeds" - || obj.Name == "Stone" - || (obj.ParentSheetIndex == 294 || obj.ParentSheetIndex == 295) + obj.Name is "Weeds" or "Stone" + || obj.ParentSheetIndex is 294 or 295 ) ) + this.RemoveResourceClumps(location, clump => this.DebrisClumps.Contains(clump.parentSheetIndex.Value)); @@ -114,7 +116,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World case "furniture": { - int removed = this.RemoveFurniture(location, furniture => true); + int removed = this.RemoveFurniture(location, _ => true); monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); break; } @@ -138,11 +140,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { bool everything = type == "everything"; int removed = - this.RemoveFurniture(location, p => true) - + this.RemoveObjects(location, p => true) - + this.RemoveTerrainFeatures(location, p => true) - + this.RemoveLargeTerrainFeatures(location, p => everything || !(p is Bush bush) || bush.isDestroyable(location, p.currentTileLocation)) - + this.RemoveResourceClumps(location, p => true); + this.RemoveFurniture(location, _ => true) + + this.RemoveObjects(location, _ => true) + + this.RemoveTerrainFeatures(location, _ => true) + + this.RemoveLargeTerrainFeatures(location, p => everything || p is not Bush bush || bush.isDestroyable(location, p.currentTileLocation)) + + this.RemoveResourceClumps(location, _ => true); monitor.Log($"Done! Removed {removed} entities from {location.Name}.", LogLevel.Info); break; } @@ -165,11 +167,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { int removed = 0; - foreach (var pair in location.Objects.Pairs.ToArray()) + foreach ((Vector2 tile, SObject? obj) in location.Objects.Pairs.ToArray()) { - if (shouldRemove(pair.Value)) + if (shouldRemove(obj)) { - location.Objects.Remove(pair.Key); + location.Objects.Remove(tile); removed++; } } @@ -185,11 +187,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { int removed = 0; - foreach (var pair in location.terrainFeatures.Pairs.ToArray()) + foreach ((Vector2 tile, TerrainFeature? feature) in location.terrainFeatures.Pairs.ToArray()) { - if (shouldRemove(pair.Value)) + if (shouldRemove(feature)) { - location.terrainFeatures.Remove(pair.Key); + location.terrainFeatures.Remove(tile); removed++; } } @@ -225,7 +227,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { int removed = 0; - foreach (var clump in location.resourceClumps.Where(shouldRemove).ToArray()) + foreach (ResourceClump clump in location.resourceClumps.Where(shouldRemove).ToArray()) { location.resourceClumps.Remove(clump); removed++; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs index 0aa9c9c3..5b1a4a13 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/DownMineLevelCommand.cs @@ -1,9 +1,11 @@ +using System.Diagnostics.CodeAnalysis; using StardewValley; using StardewValley.Locations; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which moves the player to the next mine level.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class DownMineLevelCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs index 2deac5f8..09531720 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/HurryAllCommand.cs @@ -1,9 +1,11 @@ using System; +using System.Diagnostics.CodeAnalysis; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which immediately warps all NPCs to their scheduled positions. To hurry a single NPC, see <c>debug hurry npc-name</c> instead.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class HurryAllCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs index 4028b3dc..399fd934 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetDayCommand.cs @@ -1,10 +1,12 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current day.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetDayCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs index 40f4b19f..f977fce3 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetMineLevelCommand.cs @@ -1,9 +1,11 @@ using System; +using System.Diagnostics.CodeAnalysis; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which moves the player to the given mine level.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetMineLevelCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs index a4cb35bb..505c0d1d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetSeasonCommand.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Utilities; using StardewValley; @@ -5,6 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current season.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetSeasonCommand : ConsoleCommand { /********* @@ -35,7 +37,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World } // parse arguments - if (!args.TryGet(0, "season", out string season, oneOf: this.ValidSeasons)) + if (!args.TryGet(0, "season", out string? season, oneOf: this.ValidSeasons)) return; // handle diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index 2d4b4565..8c4458dd 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Xna.Framework; using StardewValley; @@ -5,6 +6,7 @@ using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current time.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetTimeCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs index 95401962..a666a634 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetYearCommand.cs @@ -1,10 +1,12 @@ -using System.Linq; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using StardewModdingAPI.Utilities; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { /// <summary>A command which sets the current year.</summary> + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Loaded using reflection")] internal class SetYearCommand : ConsoleCommand { /********* diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs index 72d01eb7..3675a963 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/SearchableItem.cs @@ -43,16 +43,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData this.Item = createItem(this); } - /// <summary>Construct an instance.</summary> - /// <param name="item">The item metadata to copy.</param> - public SearchableItem(SearchableItem item) - { - this.Type = item.Type; - this.ID = item.ID; - this.CreateItem = item.CreateItem; - this.Item = item.Item; - } - /// <summary>Get whether the item name contains a case-insensitive substring.</summary> /// <param name="substring">The substring to find.</param> public bool NameContains(string substring) diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index 0357fe6b..3722e155 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -31,7 +31,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /// <param name="itemTypes">The item types to fetch (or null for any type).</param> /// <param name="includeVariants">Whether to include flavored variants like "Sunflower Honey".</param> [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")] - public IEnumerable<SearchableItem> GetAll(ItemType[] itemTypes = null, bool includeVariants = true) + public IEnumerable<SearchableItem> GetAll(ItemType[]? itemTypes = null, bool includeVariants = true) { // // @@ -43,9 +43,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework // // - IEnumerable<SearchableItem> GetAllRaw() + IEnumerable<SearchableItem?> GetAllRaw() { - HashSet<ItemType> types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null; + HashSet<ItemType>? types = itemTypes?.Any() == true ? new HashSet<ItemType>(itemTypes) : null; bool ShouldGet(ItemType type) => types == null || types.Contains(type); // get tools @@ -106,8 +106,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { foreach (int id in this.TryLoad<int, string>("Data\\weapons").Keys) { - yield return this.TryCreate(ItemType.Weapon, id, p => (p.ID >= 32 && p.ID <= 34) - ? (Item)new Slingshot(p.ID) + yield return this.TryCreate(ItemType.Weapon, id, p => p.ID is >= 32 and <= 34 + ? new Slingshot(p.ID) : new MeleeWeapon(p.ID) ); } @@ -132,37 +132,40 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework { foreach (int id in Game1.objectInformation.Keys) { - string[] fields = Game1.objectInformation[id]?.Split('/'); + string[]? fields = Game1.objectInformation[id]?.Split('/'); - // secret notes - if (id == 79) + // ring + if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring + { + if (ShouldGet(ItemType.Ring)) + yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); + } + + // journal scrap + else if (id == 842) { if (ShouldGet(ItemType.Object)) { - foreach (int secretNoteId in this.TryLoad<int, string>("Data\\SecretNotes").Keys) - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, _ => - { - SObject note = new SObject(79, 1); - note.name = $"{note.name} #{secretNoteId}"; - return note; - }); - } + foreach (SearchableItem? journalScrap in this.GetSecretNotes(isJournalScrap: true)) + yield return journalScrap; } } - // ring - else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring + // secret notes + else if (id == 79) { - if (ShouldGet(ItemType.Ring)) - yield return this.TryCreate(ItemType.Ring, id, p => new Ring(p.ID)); + if (ShouldGet(ItemType.Object)) + { + foreach (SearchableItem? secretNote in this.GetSecretNotes(isJournalScrap: false)) + yield return secretNote; + } } - // item + // object else if (ShouldGet(ItemType.Object)) { // spawn main item - SObject item = null; + SObject? item = null; yield return this.TryCreate(ItemType.Object, id, p => { return item = (p.ID == 812 // roe @@ -176,125 +179,179 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework // flavored items if (includeVariants) { - switch (item.Category) - { - // fruit products - case SObject.FruitsCategory: - // wine - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + item.ParentSheetIndex, _ => new SObject(348, 1) - { - Name = $"{item.Name} Wine", - Price = item.Price * 3, - preserve = { SObject.PreserveType.Wine }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // jelly - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + item.ParentSheetIndex, _ => new SObject(344, 1) - { - Name = $"{item.Name} Jelly", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Jelly }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // vegetable products - case SObject.VegetableCategory: - // juice - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + item.ParentSheetIndex, _ => new SObject(350, 1) - { - Name = $"{item.Name} Juice", - Price = (int)(item.Price * 2.25d), - preserve = { SObject.PreserveType.Juice }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - - // pickled - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => new SObject(342, 1) - { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2, - preserve = { SObject.PreserveType.Pickle }, - preservedParentSheetIndex = { item.ParentSheetIndex } - }); - break; - - // flower honey - case SObject.flowersCategory: - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + item.ParentSheetIndex, _ => - { - SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) - { - Name = $"{item.Name} Honey", - preservedParentSheetIndex = { item.ParentSheetIndex } - }; - honey.Price += item.Price * 2; - return honey; - }); - break; - - // roe and aged roe (derived from FishPond.GetFishProduce) - case SObject.sellAtFishShopCategory when item.ParentSheetIndex == 812: - { - this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> complexTags); - - foreach (var pair in Game1.objectInformation) - { - // get input - SObject input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; - var inputTags = input?.GetContextTags(); - if (inputTags?.Any() != true) - continue; - - // check if roe-producing fish - if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) - continue; - - // yield roe - SObject roe = null; - Color color = this.GetRoeColor(input); - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => - { - roe = new ColoredObject(812, 1, color) - { - name = $"{input.Name} Roe", - preserve = { Value = SObject.PreserveType.Roe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex } - }; - roe.Price += input.Price / 2; - return roe; - }); - - // aged roe - if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item - { - yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + item.ParentSheetIndex, _ => new ColoredObject(447, 1, color) - { - name = $"Aged {input.Name} Roe", - Category = -27, - preserve = { Value = SObject.PreserveType.AgedRoe }, - preservedParentSheetIndex = { Value = input.ParentSheetIndex }, - Price = roe.Price * 2 - }); - } - } - } - break; - } + foreach (SearchableItem? variant in this.GetFlavoredObjectVariants(item)) + yield return variant; } } } } } - return GetAllRaw().Where(p => p != null); + return ( + from item in GetAllRaw() + where item != null + select item + ); } /********* ** Private methods *********/ + /// <summary>Get the individual secret note or journal scrap items.</summary> + /// <param name="isJournalScrap">Whether to get journal scraps.</param> + /// <remarks>Derived from <see cref="GameLocation.tryToCreateUnseenSecretNote"/>.</remarks> + private IEnumerable<SearchableItem?> GetSecretNotes(bool isJournalScrap) + { + // get base item ID + int baseId = isJournalScrap ? 842 : 79; + + // get secret note IDs + var ids = this + .TryLoad<int, string>("Data\\SecretNotes") + .Keys + .Where(isJournalScrap + ? id => (id >= GameLocation.JOURNAL_INDEX) + : id => (id < GameLocation.JOURNAL_INDEX) + ) + .Select<int, int>(isJournalScrap + ? id => (id - GameLocation.JOURNAL_INDEX) + : id => id + ); + + // build items + foreach (int id in ids) + { + int fakeId = this.CustomIDOffset * 8 + id; + if (isJournalScrap) + fakeId += GameLocation.JOURNAL_INDEX; + + yield return this.TryCreate(ItemType.Object, fakeId, _ => + { + SObject note = new(baseId, 1); + note.Name = $"{note.Name} #{id}"; + return note; + }); + } + } + + /// <summary>Get flavored variants of a base item (like Blueberry Wine for Blueberry), if any.</summary> + /// <param name="item">A sample of the base item.</param> + private IEnumerable<SearchableItem?> GetFlavoredObjectVariants(SObject item) + { + int id = item.ParentSheetIndex; + + switch (item.Category) + { + // fruit products + case SObject.FruitsCategory: + // wine + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, _ => new SObject(348, 1) + { + Name = $"{item.Name} Wine", + Price = item.Price * 3, + preserve = { SObject.PreserveType.Wine }, + preservedParentSheetIndex = { id } + }); + + // jelly + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, _ => new SObject(344, 1) + { + Name = $"{item.Name} Jelly", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Jelly }, + preservedParentSheetIndex = { id } + }); + break; + + // vegetable products + case SObject.VegetableCategory: + // juice + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, _ => new SObject(350, 1) + { + Name = $"{item.Name} Juice", + Price = (int)(item.Price * 2.25d), + preserve = { SObject.PreserveType.Juice }, + preservedParentSheetIndex = { id } + }); + + // pickled + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => new SObject(342, 1) + { + Name = $"Pickled {item.Name}", + Price = 50 + item.Price * 2, + preserve = { SObject.PreserveType.Pickle }, + preservedParentSheetIndex = { id } + }); + break; + + // flower honey + case SObject.flowersCategory: + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, _ => + { + SObject honey = new(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) + { + Name = $"{item.Name} Honey", + preservedParentSheetIndex = { id } + }; + honey.Price += item.Price * 2; + return honey; + }); + break; + + // roe and aged roe (derived from FishPond.GetFishProduce) + case SObject.sellAtFishShopCategory when id == 812: + { + this.GetRoeContextTagLookups(out HashSet<string> simpleTags, out List<List<string>> complexTags); + + foreach (var pair in Game1.objectInformation) + { + // get input + SObject? input = this.TryCreate(ItemType.Object, pair.Key, p => new SObject(p.ID, 1))?.Item as SObject; + if (input == null) + continue; + + HashSet<string> inputTags = input.GetContextTags(); + if (!inputTags.Any()) + continue; + + // check if roe-producing fish + if (!inputTags.Any(tag => simpleTags.Contains(tag)) && !complexTags.Any(set => set.All(tag => input.HasContextTag(tag)))) + continue; + + // yield roe + SObject? roe = null; + Color color = this.GetRoeColor(input); + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => + { + roe = new ColoredObject(812, 1, color) + { + name = $"{input.Name} Roe", + preserve = { Value = SObject.PreserveType.Roe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex } + }; + roe.Price += input.Price / 2; + return roe; + }); + + // aged roe + if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, _ => new ColoredObject(447, 1, color) + { + name = $"Aged {input.Name} Roe", + Category = -27, + preserve = { Value = SObject.PreserveType.AgedRoe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex }, + Price = roe.Price * 2 + }); + } + } + } + break; + } + } + /// <summary>Get optimized lookups to match items which produce roe in a fish pond.</summary> /// <param name="simpleTags">A lookup of simple singular tags which match a roe-producing fish.</param> /// <param name="complexTags">A list of tag sets which match roe-producing fish.</param> @@ -320,6 +377,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /// <typeparam name="TValue">The asset value type.</typeparam> /// <param name="assetName">The data asset name.</param> private Dictionary<TKey, TValue> TryLoad<TKey, TValue>(string assetName) + where TKey : notnull { try { @@ -336,7 +394,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework /// <param name="type">The item type.</param> /// <param name="id">The unique ID (if different from the item's parent sheet index).</param> /// <param name="createItem">Create an item instance.</param> - private SearchableItem TryCreate(ItemType type, int id, Func<SearchableItem, Item> createItem) + private SearchableItem? TryCreate(ItemType type, int id, Func<SearchableItem, Item> createItem) { try { diff --git a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs index 91437fd3..dbfca815 100644 --- a/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs +++ b/src/SMAPI.Mods.ConsoleCommands/ModEntry.cs @@ -13,13 +13,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands ** Fields *********/ /// <summary>The commands to handle.</summary> - private IConsoleCommand[] Commands; + private IConsoleCommand[] Commands = null!; /// <summary>The commands which may need to handle update ticks.</summary> - private IConsoleCommand[] UpdateHandlers; + private IConsoleCommand[] UpdateHandlers = null!; /// <summary>The commands which may need to handle input.</summary> - private IConsoleCommand[] InputHandlers; + private IConsoleCommand[] InputHandlers = null!; /********* @@ -50,7 +50,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <summary>The method invoked when a button is pressed.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private void OnButtonPressed(object sender, ButtonPressedEventArgs e) + private void OnButtonPressed(object? sender, ButtonPressedEventArgs e) { foreach (IConsoleCommand command in this.InputHandlers) command.OnButtonPressed(this.Monitor, e.Button); @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <summary>The method invoked when the game updates its state.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private void OnUpdateTicked(object sender, EventArgs e) + private void OnUpdateTicked(object? sender, EventArgs e) { foreach (IConsoleCommand command in this.UpdateHandlers) command.OnUpdated(this.Monitor); @@ -71,7 +71,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands /// <param name="args">The command arguments.</param> private void HandleCommand(IConsoleCommand command, string commandName, string[] args) { - ArgumentParser argParser = new ArgumentParser(commandName, args, this.Monitor); + ArgumentParser argParser = new(commandName, args, this.Monitor); command.Handle(this.Monitor, commandName, argParser); } @@ -81,7 +81,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands return ( from type in this.GetType().Assembly.GetTypes() where !type.IsAbstract && typeof(IConsoleCommand).IsAssignableFrom(type) - select (IConsoleCommand)Activator.CreateInstance(type) + select (IConsoleCommand)Activator.CreateInstance(type)! ); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index fa5f6d6f..38945c5d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "3.13.4", + "Version": "3.14.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "3.13.4" + "MinimumApiVersion": "3.14.0" } diff --git a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs index 067f6a8d..bfbfd2dc 100644 --- a/src/SMAPI.Mods.ErrorHandler/ModEntry.cs +++ b/src/SMAPI.Mods.ErrorHandler/ModEntry.cs @@ -30,7 +30,6 @@ namespace StardewModdingAPI.Mods.ErrorHandler // apply patches HarmonyPatcher.Apply(this.ModManifest.UniqueID, this.Monitor, new DialoguePatcher(monitorForGame, this.Helper.Reflection), - new DictionaryPatcher(this.Helper.Reflection), new EventPatcher(monitorForGame), new GameLocationPatcher(monitorForGame), new IClickableMenuPatcher(), @@ -58,7 +57,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler /// <summary>The method invoked when a save is loaded.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private void OnSaveLoaded(object sender, SaveLoadedEventArgs e) + private void OnSaveLoaded(object? sender, SaveLoadedEventArgs e) { // show in-game warning for removed save content if (this.IsSaveContentRemoved) @@ -81,7 +80,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler MethodInfo getMonitorForGame = coreType.GetMethod("GetMonitorForGame") ?? throw new InvalidOperationException("Can't access the SMAPI's 'GetMonitorForGame' method. This mod may not work correctly."); - return (IMonitor)getMonitorForGame.Invoke(core, new object[0]) ?? this.Monitor; + return (IMonitor?)getMonitorForGame.Invoke(core, Array.Empty<object>()) ?? this.Monitor; } } } diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs index 7a3af39c..e98eec3c 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/DialoguePatcher.cs @@ -17,10 +17,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Fields *********/ /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; + private static IMonitor MonitorForGame = null!; /// <summary>Simplifies access to private code.</summary> - private static IReflectionHelper Reflection; + private static IReflectionHelper Reflection = null!; /********* @@ -54,12 +54,12 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC speaker, Exception __exception) + private static Exception? Finalize_Constructor(Dialogue __instance, string masterDialogue, NPC? speaker, Exception? __exception) { if (__exception != null) { // log message - string name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; + string? name = !string.IsNullOrWhiteSpace(speaker?.Name) ? speaker.Name : null; DialoguePatcher.MonitorForGame.Log($"Failed parsing dialogue string{(name != null ? $" for {name}" : "")}:\n{masterDialogue}\n{__exception.GetLogSummary()}", LogLevel.Error); // set default dialogue diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs deleted file mode 100644 index 8ceafcc5..00000000 --- a/src/SMAPI.Mods.ErrorHandler/Patches/DictionaryPatcher.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using HarmonyLib; -using StardewModdingAPI.Internal.Patching; -using StardewValley.GameData; -using StardewValley.GameData.HomeRenovations; -using StardewValley.GameData.Movies; - -namespace StardewModdingAPI.Mods.ErrorHandler.Patches -{ - /// <summary>Harmony patches for <see cref="Dictionary{TKey,TValue}"/> which add the accessed key to <see cref="KeyNotFoundException"/> exceptions.</summary> - /// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")] - internal class DictionaryPatcher : BasePatcher - { - /********* - ** Fields - *********/ - /// <summary>Simplifies access to private code.</summary> - private static IReflectionHelper Reflection; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="reflector">Simplifies access to private code.</param> - public DictionaryPatcher(IReflectionHelper reflector) - { - DictionaryPatcher.Reflection = reflector; - } - - /// <inheritdoc /> - public override void Apply(Harmony harmony, IMonitor monitor) - { - Type[] keyTypes = { typeof(int), typeof(string) }; - Type[] valueTypes = { typeof(int), typeof(string), typeof(HomeRenovation), typeof(MovieData), typeof(SpecialOrderData) }; - - foreach (Type keyType in keyTypes) - { - foreach (Type valueType in valueTypes) - { - Type dictionaryType = typeof(Dictionary<,>).MakeGenericType(keyType, valueType); - - harmony.Patch( - original: AccessTools.Method(dictionaryType, "get_Item") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "get_Item")} to patch."), - finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_GetItem)) - ); - - harmony.Patch( - original: AccessTools.Method(dictionaryType, "Add") ?? throw new InvalidOperationException($"Can't find method {PatchHelper.GetMethodString(dictionaryType, "Add")} to patch."), - finalizer: this.GetHarmonyMethod(nameof(DictionaryPatcher.Finalize_Add)) - ); - } - } - } - - - /********* - ** Private methods - *********/ - /// <summary>The method to call after the dictionary indexer throws an exception.</summary> - /// <param name="key">The dictionary key being fetched.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_GetItem(object key, Exception __exception) - { - if (__exception is KeyNotFoundException) - DictionaryPatcher.AddKey(__exception, key); - - return __exception; - } - - /// <summary>The method to call after a dictionary insert throws an exception.</summary> - /// <param name="key">The dictionary key being inserted.</param> - /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> - /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Add(object key, Exception __exception) - { - if (__exception is ArgumentException) - DictionaryPatcher.AddKey(__exception, key); - - return __exception; - } - - /// <summary>Add the dictionary key to an exception message.</summary> - /// <param name="exception">The exception whose message to edit.</param> - /// <param name="key">The dictionary key.</param> - private static void AddKey(Exception exception, object key) - { - DictionaryPatcher.Reflection - .GetField<string>(exception, "_message") - .SetValue($"{exception.Message}\nkey: '{key}'"); - } - } -} diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs index 1b706147..073c62cc 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/EventPatcher.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Fields *********/ /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; + private static IMonitor MonitorForGame = null!; /********* diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs index 7df6b0a2..9247fa48 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/GameLocationPatcher.cs @@ -17,8 +17,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Fields *********/ /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; - + private static IMonitor MonitorForGame = null!; /********* ** Public methods @@ -52,7 +51,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="precondition">The precondition to be parsed.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception __exception) + private static Exception? Finalize_CheckEventPrecondition(ref int __result, string precondition, Exception? __exception) { if (__exception != null) { @@ -68,7 +67,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="map">The map whose tilesheets to update.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception __exception) + private static Exception? Finalize_UpdateSeasonalTileSheets(GameLocation __instance, Map map, Exception? __exception) { if (__exception != null) GameLocationPatcher.MonitorForGame.Log($"Failed updating seasonal tilesheets for location '{__instance.NameOrUniqueName}': \n{__exception}", LogLevel.Error); diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs index 275bb5bf..11f7ec69 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/NpcPatcher.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Fields *********/ /// <summary>Writes messages to the console and log file on behalf of the game.</summary> - private static IMonitor MonitorForGame; + private static IMonitor MonitorForGame = null!; /********* @@ -54,7 +54,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="__result">The return value of the original method.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception __exception) + private static Exception? Finalize_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, Exception? __exception) { if (__exception == null) return null; @@ -71,7 +71,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="__result">The patched method's return value.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception __exception) + private static Exception? Finalize_ParseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, Exception? __exception) { if (__exception != null) { diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs index fd4ea35c..09a6fbbd 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/ObjectPatcher.cs @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="__result">The patched method's return value.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_Object_loadDisplayName(ref string __result, Exception __exception) + private static Exception? Finalize_Object_loadDisplayName(ref string __result, Exception? __exception) { if (__exception is KeyNotFoundException) { diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs index 0a7ed212..490bbfb6 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/SaveGamePatcher.cs @@ -22,10 +22,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches ** Fields *********/ /// <summary>Writes messages to the console and log file.</summary> - private static IMonitor Monitor; + private static IMonitor Monitor = null!; /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> - private static Action OnContentRemoved; + private static Action OnContentRemoved = null!; /********* @@ -74,10 +74,10 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <summary>The method to call after <see cref="SaveGame.LoadFarmType"/> throws an exception.</summary> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_LoadFarmType(Exception __exception) + private static Exception? Finalize_LoadFarmType(Exception? __exception) { // missing custom farm type - if (__exception?.Message?.Contains("not a valid farm type") == true && !int.TryParse(SaveGame.loaded.whichFarm, out _)) + if (__exception?.Message.Contains("not a valid farm type") == true && !int.TryParse(SaveGame.loaded.whichFarm, out _)) { SaveGamePatcher.Monitor.Log(__exception.GetLogSummary(), LogLevel.Error); SaveGamePatcher.Monitor.Log($"Removed invalid custom farm type '{SaveGame.loaded.whichFarm}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom farm type mod?)", LogLevel.Warn); @@ -108,7 +108,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <summary>Remove content which no longer exists in the game data.</summary> /// <param name="location">The current game location.</param> /// <param name="npcs">The NPC data.</param> - private static bool RemoveBrokenContent(GameLocation location, IDictionary<string, string> npcs) + private static bool RemoveBrokenContent(GameLocation? location, IDictionary<string, string> npcs) { bool removedAny = false; if (location == null) @@ -121,7 +121,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches { try { - BluePrint _ = new BluePrint(building.buildingType.Value); + BluePrint _ = new(building.buildingType.Value); } catch (ContentLoadException) { diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs index f243c6d1..d369e0ef 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/SpriteBatchPatcher.cs @@ -28,9 +28,9 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /********* ** Private methods *********/ - /// <summary>The method to call after <see cref="SpriteBatch.CheckValid"/>.</summary> + /// <summary>The method to call after <see cref="SpriteBatch.CheckValid(Texture2D)"/>.</summary> /// <param name="texture">The texture to validate.</param> - private static void After_CheckValid(Texture2D texture) + private static void After_CheckValid(Texture2D? texture) { if (texture?.IsDisposed == true) throw new ObjectDisposedException("Cannot draw this texture because it's disposed."); diff --git a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs index ce85d0c2..6d75a581 100644 --- a/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs +++ b/src/SMAPI.Mods.ErrorHandler/Patches/UtilityPatcher.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Mods.ErrorHandler.Patches /// <param name="delimiter">The delimiter by which to split the text description.</param> /// <param name="__exception">The exception thrown by the wrapped method, if any.</param> /// <returns>Returns the exception to throw, if any.</returns> - private static Exception Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception __exception) + private static Exception? Finalize_GetItemFromStandardTextDescription(string description, char delimiter, ref Exception? __exception) { return __exception != null ? new FormatException($"Failed to parse item text description \"{description}\" with delimiter \"{delimiter}\".", __exception) diff --git a/src/SMAPI.Mods.ErrorHandler/manifest.json b/src/SMAPI.Mods.ErrorHandler/manifest.json index 11bc0b42..c082bf75 100644 --- a/src/SMAPI.Mods.ErrorHandler/manifest.json +++ b/src/SMAPI.Mods.ErrorHandler/manifest.json @@ -1,9 +1,9 @@ { "Name": "Error Handler", "Author": "SMAPI", - "Version": "3.13.4", + "Version": "3.14.0", "Description": "Handles some common vanilla errors to log more useful info or avoid breaking the game.", "UniqueID": "SMAPI.ErrorHandler", "EntryDll": "ErrorHandler.dll", - "MinimumApiVersion": "3.13.4" + "MinimumApiVersion": "3.14.0" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index d6414e9c..a79c092f 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Linq; @@ -19,7 +20,7 @@ namespace StardewModdingAPI.Mods.SaveBackup private readonly int BackupsToKeep = 10; /// <summary>The absolute path to the folder in which to store save backups.</summary> - private readonly string BackupFolder = Path.Combine(Constants.ExecutionPath, "save-backups"); + private readonly string BackupFolder = Path.Combine(Constants.GamePath, "save-backups"); /// <summary>A unique label for the save backup to create.</summary> private readonly string BackupLabel = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}"; @@ -38,13 +39,13 @@ namespace StardewModdingAPI.Mods.SaveBackup try { // init backup folder - DirectoryInfo backupFolder = new DirectoryInfo(this.BackupFolder); + DirectoryInfo backupFolder = new(this.BackupFolder); backupFolder.Create(); // back up & prune saves Task .Run(() => this.CreateBackup(backupFolder)) - .ContinueWith(backupTask => this.PruneBackups(backupFolder, this.BackupsToKeep)); + .ContinueWith(_ => this.PruneBackups(backupFolder, this.BackupsToKeep)); } catch (Exception ex) { @@ -63,8 +64,8 @@ namespace StardewModdingAPI.Mods.SaveBackup try { // get target path - FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName)); - DirectoryInfo fallbackDir = new DirectoryInfo(Path.Combine(backupFolder.FullName, this.BackupLabel)); + FileInfo targetFile = new(Path.Combine(backupFolder.FullName, this.FileName)); + DirectoryInfo fallbackDir = new(Path.Combine(backupFolder.FullName, this.BackupLabel)); if (targetFile.Exists || fallbackDir.Exists) { this.Monitor.Log("Already backed up today."); @@ -72,7 +73,7 @@ namespace StardewModdingAPI.Mods.SaveBackup } // copy saves to fallback directory (ignore non-save files/folders) - DirectoryInfo savesDir = new DirectoryInfo(Constants.SavesPath); + DirectoryInfo savesDir = new(Constants.SavesPath); if (!this.RecursiveCopy(savesDir, fallbackDir, entry => this.MatchSaveFolders(savesDir, entry), copyRoot: false)) { this.Monitor.Log("No saves found."); @@ -80,7 +81,7 @@ namespace StardewModdingAPI.Mods.SaveBackup } // compress backup if possible - if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception compressError)) + if (!this.TryCompress(fallbackDir.FullName, targetFile, out Exception? compressError)) { this.Monitor.Log(Constants.TargetPlatform != GamePlatform.Android ? $"Backed up to {fallbackDir.FullName}." // expected to fail on Android @@ -140,7 +141,7 @@ namespace StardewModdingAPI.Mods.SaveBackup /// <param name="destination">The destination file to create.</param> /// <param name="error">The error which occurred trying to compress, if applicable. This is <see cref="NotSupportedException"/> if compression isn't supported on this platform.</param> /// <returns>Returns whether compression succeeded.</returns> - private bool TryCompress(string sourcePath, FileInfo destination, out Exception error) + private bool TryCompress(string sourcePath, FileInfo destination, [NotNullWhen(false)] out Exception? error) { try { @@ -170,8 +171,8 @@ namespace StardewModdingAPI.Mods.SaveBackup try { // create compressed backup - Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly."); - Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly."); + Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"); + Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089"); Type compressionLevelType = coreAssembly.GetType("System.IO.Compression.CompressionLevel") ?? throw new InvalidOperationException("Can't load CompressionLevel type."); Type zipFileType = fsAssembly.GetType("System.IO.Compression.ZipFile") ?? throw new InvalidOperationException("Can't load ZipFile type."); createFromDirectory = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method."); @@ -190,8 +191,8 @@ namespace StardewModdingAPI.Mods.SaveBackup /// <param name="destination">The destination file to create.</param> private void CompressUsingMacProcess(string sourcePath, FileInfo destination) { - DirectoryInfo saveFolder = new DirectoryInfo(sourcePath); - ProcessStartInfo startInfo = new ProcessStartInfo + DirectoryInfo saveFolder = new(sourcePath); + ProcessStartInfo startInfo = new() { FileName = "zip", Arguments = $"-rq \"{destination.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"", @@ -208,7 +209,7 @@ namespace StardewModdingAPI.Mods.SaveBackup /// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param> /// <remarks>Derived from the SMAPI installer code.</remarks> /// <returns>Returns whether any files were copied.</returns> - private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true) + private bool RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool>? filter, bool copyRoot = true) { if (!source.Exists || filter?.Invoke(source) == false) return false; @@ -242,7 +243,7 @@ namespace StardewModdingAPI.Mods.SaveBackup private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry) { // only need to filter top-level entries - string parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName; + string? parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName; if (parentPath != savesFolder.FullName) return true; diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index c50cff14..28bffa98 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "3.13.4", + "Version": "3.14.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "3.13.4" + "MinimumApiVersion": "3.14.0" } diff --git a/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs new file mode 100644 index 00000000..ac7bd338 --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/ApiConsumer.cs @@ -0,0 +1,46 @@ +using System; +using SMAPI.Tests.ModApiConsumer.Interfaces; + +namespace SMAPI.Tests.ModApiConsumer +{ + /// <summary>A simulated API consumer.</summary> + public class ApiConsumer + { + /********* + ** Public methods + *********/ + /// <summary>Call the event field on the given API.</summary> + /// <param name="api">The API to call.</param> + /// <param name="getValues">Get the number of times the event was called and the last value received.</param> + public void UseEventField(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + { + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaised += (_, value) => + { + calls++; + lastValue = value; + }; + + getValues = () => (timesCalled: calls, actualValue: lastValue); + } + + /// <summary>Call the event property on the given API.</summary> + /// <param name="api">The API to call.</param> + /// <param name="getValues">Get the number of times the event was called and the last value received.</param> + public void UseEventProperty(ISimpleApi api, out Func<(int timesCalled, int actualValue)> getValues) + { + // act + int calls = 0; + int lastValue = -1; + api.OnEventRaisedProperty += (_, value) => + { + calls++; + lastValue = value; + }; + + getValues = () => (timesCalled: calls, actualValue: lastValue); + } + } +} diff --git a/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs new file mode 100644 index 00000000..c99605e4 --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/Interfaces/ISimpleApi.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using StardewModdingAPI.Utilities; + +namespace SMAPI.Tests.ModApiConsumer.Interfaces +{ + /// <summary>A mod-provided API which provides basic events, properties, and methods.</summary> + public interface ISimpleApi + { + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// <summary>A simple event field.</summary> + event EventHandler<int> OnEventRaised; + + /// <summary>A simple event property with custom add/remove logic.</summary> + event EventHandler<int> OnEventRaisedProperty; + + + /**** + ** Properties + ****/ + /// <summary>A simple numeric property.</summary> + int NumberProperty { get; set; } + + /// <summary>A simple object property.</summary> + object ObjectProperty { get; set; } + + /// <summary>A simple list property.</summary> + List<string> ListProperty { get; set; } + + /// <summary>A simple list property with an interface.</summary> + IList<string> ListPropertyWithInterface { get; set; } + + /// <summary>A property with nested generics.</summary> + IDictionary<string, IList<string>> GenericsProperty { get; set; } + + /// <summary>A property using an enum available to both mods.</summary> + BindingFlags EnumProperty { get; set; } + + /// <summary>A read-only property.</summary> + int GetterProperty { get; } + + + /**** + ** Methods + ****/ + /// <summary>A simple method with no return value.</summary> + void GetNothing(); + + /// <summary>A simple method which returns a number.</summary> + int GetInt(int value); + + /// <summary>A simple method which returns an object.</summary> + object GetObject(object value); + + /// <summary>A simple method which returns a list.</summary> + List<string> GetList(string value); + + /// <summary>A simple method which returns a list with an interface.</summary> + IList<string> GetListWithInterface(string value); + + /// <summary>A simple method which returns nested generics.</summary> + IDictionary<string, IList<string>> GetGenerics(string key, string value); + + /// <summary>A simple method which returns a lambda.</summary> + Func<string, string> GetLambda(Func<string, string> value); + + /// <summary>A simple method which returns out parameters.</summary> + bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType); + + + /**** + ** Inherited members + ****/ + /// <summary>A property inherited from a base class.</summary> + public string InheritedProperty { get; set; } + } +} diff --git a/src/SMAPI.Tests.ModApiConsumer/README.md b/src/SMAPI.Tests.ModApiConsumer/README.md new file mode 100644 index 00000000..ed0c6e3f --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/README.md @@ -0,0 +1,3 @@ +This project contains a simulated [mod-provided API] consumer used in the API proxying unit tests. + +[mod-provided API]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations diff --git a/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj new file mode 100644 index 00000000..7fef4ebd --- /dev/null +++ b/src/SMAPI.Tests.ModApiConsumer/SMAPI.Tests.ModApiConsumer.csproj @@ -0,0 +1,11 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + </PropertyGroup> + + <Import Project="..\..\build\common.targets" /> + + <ItemGroup> + <ProjectReference Include="..\SMAPI\SMAPI.csproj" /> + </ItemGroup> +</Project> diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs new file mode 100644 index 00000000..77001e4c --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/Framework/BaseApi.cs @@ -0,0 +1,12 @@ +namespace SMAPI.Tests.ModApiProvider.Framework +{ + /// <summary>The base class for <see cref="SimpleApi"/>.</summary> + public class BaseApi + { + /********* + ** Test interface + *********/ + /// <summary>A property inherited from a base class.</summary> + public string? InheritedProperty { get; set; } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs new file mode 100644 index 00000000..c8781da5 --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/Framework/SimpleApi.cs @@ -0,0 +1,125 @@ +// ReSharper disable UnusedMember.Global -- used dynamically through proxies + +using System; +using System.Collections.Generic; +using System.Reflection; +using StardewModdingAPI.Utilities; + +namespace SMAPI.Tests.ModApiProvider.Framework +{ + /// <summary>A mod-provided API which provides basic events, properties, and methods.</summary> + public class SimpleApi : BaseApi + { + /********* + ** Test interface + *********/ + /**** + ** Events + ****/ + /// <summary>A simple event field.</summary> + public event EventHandler<int>? OnEventRaised; + + /// <summary>A simple event property with custom add/remove logic.</summary> + public event EventHandler<int> OnEventRaisedProperty + { + add => this.OnEventRaised += value; + remove => this.OnEventRaised -= value; + } + + + /**** + ** Properties + ****/ + /// <summary>A simple numeric property.</summary> + public int NumberProperty { get; set; } + + /// <summary>A simple object property.</summary> + public object? ObjectProperty { get; set; } + + /// <summary>A simple list property.</summary> + public List<string>? ListProperty { get; set; } + + /// <summary>A simple list property with an interface.</summary> + public IList<string>? ListPropertyWithInterface { get; set; } + + /// <summary>A property with nested generics.</summary> + public IDictionary<string, IList<string>>? GenericsProperty { get; set; } + + /// <summary>A property using an enum available to both mods.</summary> + public BindingFlags EnumProperty { get; set; } + + /// <summary>A read-only property.</summary> + public int GetterProperty => 42; + + + /**** + ** Methods + ****/ + /// <summary>A simple method with no return value.</summary> + public void GetNothing() { } + + /// <summary>A simple method which returns a number.</summary> + public int GetInt(int value) + { + return value; + } + + /// <summary>A simple method which returns an object.</summary> + public object GetObject(object value) + { + return value; + } + + /// <summary>A simple method which returns a list.</summary> + public List<string> GetList(string value) + { + return new() { value }; + } + + /// <summary>A simple method which returns a list with an interface.</summary> + public IList<string> GetListWithInterface(string value) + { + return new List<string> { value }; + } + + /// <summary>A simple method which returns nested generics.</summary> + public IDictionary<string, IList<string>> GetGenerics(string key, string value) + { + return new Dictionary<string, IList<string>> + { + [key] = new List<string> { value } + }; + } + + /// <summary>A simple method which returns a lambda.</summary> + public Func<string, string> GetLambda(Func<string, string> value) + { + return value; + } + + /// <summary>A simple method which returns out parameters.</summary> + public bool TryGetOutParameter(int inputNumber, out int outNumber, out string outString, out PerScreen<int> outReference, out IDictionary<int, PerScreen<int>> outComplexType) + { + outNumber = inputNumber; + outString = inputNumber.ToString(); + outReference = new PerScreen<int>(() => inputNumber); + outComplexType = new Dictionary<int, PerScreen<int>> + { + [inputNumber] = new PerScreen<int>(() => inputNumber) + }; + + return true; + } + + + /********* + ** Helper methods + *********/ + /// <summary>Raise the <see cref="OnEventRaised"/> event.</summary> + /// <param name="value">The value to pass to the event.</param> + public void RaiseEventField(int value) + { + this.OnEventRaised?.Invoke(null, value); + } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs new file mode 100644 index 00000000..c36e1c6d --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/ProviderMod.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Reflection; +using SMAPI.Tests.ModApiProvider.Framework; + +namespace SMAPI.Tests.ModApiProvider +{ + /// <summary>A simulated mod instance.</summary> + public class ProviderMod + { + /// <summary>The underlying API instance.</summary> + private readonly SimpleApi Api = new(); + + /// <summary>Get the mod API instance.</summary> + public object GetModApi() + { + return this.Api; + } + + /// <summary>Raise the <see cref="SimpleApi.OnEventRaised"/> event.</summary> + /// <param name="value">The value to send as an event argument.</param> + public void RaiseEvent(int value) + { + this.Api.RaiseEventField(value); + } + + /// <summary>Set the values for the API property.</summary> + public void SetPropertyValues(int number, object obj, string listValue, string listWithInterfaceValue, string dictionaryKey, string dictionaryListValue, BindingFlags enumValue, string inheritedValue) + { + this.Api.NumberProperty = number; + this.Api.ObjectProperty = obj; + this.Api.ListProperty = new List<string> { listValue }; + this.Api.ListPropertyWithInterface = new List<string> { listWithInterfaceValue }; + this.Api.GenericsProperty = new Dictionary<string, IList<string>> { [dictionaryKey] = new List<string> { dictionaryListValue } }; + this.Api.EnumProperty = enumValue; + this.Api.InheritedProperty = inheritedValue; + } + } +} diff --git a/src/SMAPI.Tests.ModApiProvider/README.md b/src/SMAPI.Tests.ModApiProvider/README.md new file mode 100644 index 00000000..c79838e0 --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/README.md @@ -0,0 +1,3 @@ +This project contains simulated [mod-provided APIs] used in the API proxying unit tests. + +[mod-provided APIs]: https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Integrations diff --git a/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj new file mode 100644 index 00000000..7fef4ebd --- /dev/null +++ b/src/SMAPI.Tests.ModApiProvider/SMAPI.Tests.ModApiProvider.csproj @@ -0,0 +1,11 @@ +<Project Sdk="Microsoft.NET.Sdk"> + <PropertyGroup> + <TargetFramework>net5.0</TargetFramework> + </PropertyGroup> + + <Import Project="..\..\build\common.targets" /> + + <ItemGroup> + <ProjectReference Include="..\SMAPI\SMAPI.csproj" /> + </ItemGroup> +</Project> diff --git a/src/SMAPI.Tests/Core/AssetNameTests.cs b/src/SMAPI.Tests/Core/AssetNameTests.cs new file mode 100644 index 00000000..655e9bae --- /dev/null +++ b/src/SMAPI.Tests/Core/AssetNameTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using NUnit.Framework; +using StardewModdingAPI; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace SMAPI.Tests.Core +{ + /// <summary>Unit tests for <see cref="AssetName"/>.</summary> + [TestFixture] + internal class AssetNameTests + { + /********* + ** Unit tests + *********/ + /**** + ** Constructor + ****/ + [Test(Description = $"Assert that the {nameof(AssetName)} constructor creates an instance with the expected values.")] + [TestCase("SimpleName", "SimpleName", null, null)] + [TestCase("Data/Achievements", "Data/Achievements", null, null)] + [TestCase("Characters/Dialogue/Abigail", "Characters/Dialogue/Abigail", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + [TestCase("Characters/Dialogue\\Abigail.fr-FR", "Characters/Dialogue/Abigail.fr-FR", null, null)] + [TestCase("Characters/Dialogue/Abigail.fr-FR", "Characters/Dialogue/Abigail", "fr-FR", LocalizedContentManager.LanguageCode.fr)] + public void Constructor_Valid(string name, string expectedBaseName, string? expectedLocale, LocalizedContentManager.LanguageCode? expectedLanguageCode) + { + // arrange + name = PathUtilities.NormalizeAssetName(name); + + // act + IAssetName assetName = AssetName.Parse(name, parseLocale: _ => expectedLanguageCode); + + // assert + assetName.Name.Should() + .NotBeNull() + .And.Be(name.Replace("\\", "/")); + assetName.BaseName.Should() + .NotBeNull() + .And.Be(expectedBaseName); + assetName.LocaleCode.Should() + .Be(expectedLocale); + assetName.LanguageCode.Should() + .Be(expectedLanguageCode); + } + + [Test(Description = $"Assert that the {nameof(AssetName)} constructor throws an exception if the value is invalid.")] + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("\t")] + [TestCase(" \t ")] + public void Constructor_NullOrWhitespace(string? name) + { + // act + ArgumentException exception = Assert.Throws<ArgumentException>(() => _ = AssetName.Parse(name!, _ => null))!; + + // assert + exception.ParamName.Should().Be("rawName"); + exception.Message.Should().Be("The asset name can't be null or empty. (Parameter 'rawName')"); + } + + + /**** + ** IsEquivalentTo + ****/ + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is included.")] + + // exact match (ignore case) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + + // exact match (ignore formatting) + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + + // whitespace-insensitive + [TestCase("Data/Achievements", " Data/Achievements ", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + + // other is null or whitespace + [TestCase("Data/Achievements", null, ExpectedResult = false)] + [TestCase("Data/Achievements", "", ExpectedResult = false)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = true)] + public bool IsEquivalentTo_Name(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + + // assert + return name.IsEquivalentTo(otherAssetName); + } + + [Test(Description = $"Assert that {nameof(AssetName.IsEquivalentTo)} compares names as expected when the locale is excluded.")] + + // a few samples from previous test to make sure + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements", " ", ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements", "Data/Achievements.fr-FR", ExpectedResult = false)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements.fr-FR", ExpectedResult = false)] + public bool IsEquivalentTo_BaseName(string mainAssetName, string otherAssetName) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => LocalizedContentManager.LanguageCode.fr); + + // assert + return name.IsEquivalentTo(otherAssetName, useBaseName: true); + } + + + /**** + ** StartsWith + ****/ + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for inputs that aren't affected by the input options.")] + + // exact match (ignore case and formatting) + [TestCase("Data/Achievements", "Data/Achievements", ExpectedResult = true)] + [TestCase("DATA/achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("Data/Achievements", "Data\\Achievements", ExpectedResult = true)] + [TestCase("DATA\\achievements", "data/ACHIEVEMENTS", ExpectedResult = true)] + [TestCase("DATA\\\\achievements", "data////ACHIEVEMENTS", ExpectedResult = true)] + + // whitespace-insensitive + [TestCase("Data/Achievements", " Data/Achievements", ExpectedResult = true)] + [TestCase(" Data/Achievements ", "Data/Achievements", ExpectedResult = true)] + [TestCase("Data/Achievements", " ", ExpectedResult = true)] + + // invalid prefixes + [TestCase("Data/Achievements", null, ExpectedResult = false)] + + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", ExpectedResult = true)] + public bool StartsWith_SimpleCases(string mainAssetName, string prefix) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix); + foreach (bool allowPartialWord in new[] { true, false }) + { + foreach (bool allowSubfolder in new[] { true, true }) + { + if (allowPartialWord && allowSubfolder) + continue; + + name.StartsWith(prefix, allowPartialWord, allowSubfolder) + .Should().Be(result, $"the value returned for options ({nameof(allowPartialWord)}: {allowPartialWord}, {nameof(allowSubfolder)}: {allowSubfolder}) should match the base case"); + } + } + + // assert value + return result; + } + + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowPartialWord' option.")] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/AchievementsToIgnore", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements X", "Data/Achievements", false, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.X", "Data/Achievements", false, ExpectedResult = true)] + + // with locale codes + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements.fr-FR", "Data/Achievements", false, ExpectedResult = true)] + public bool StartsWith_PartialWord(string mainAssetName, string prefix, bool allowPartialWord) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(prefix, allowPartialWord: allowPartialWord, allowSubfolder: true); + name.StartsWith(prefix, allowPartialWord, allowSubfolder: false) + .Should().Be(result, "specifying allowSubfolder should have no effect for these inputs"); + + // assert value + return result; + } + + [Test(Description = $"Assert that {nameof(AssetName.StartsWith)} compares names as expected for the 'allowSubfolder' option.")] + + // simple cases + [TestCase("Data/Achievements/Path", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/Achievements", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\Achievements", false, ExpectedResult = false)] + + // trailing slash + [TestCase("Data/Achievements/Path", "Data/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/", false, ExpectedResult = false)] + + // normalize slash style + [TestCase("Data/Achievements/Path", "Data\\", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data\\", false, ExpectedResult = false)] + [TestCase("Data/Achievements/Path", "Data/\\/", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path", "Data/\\/", false, ExpectedResult = false)] + + // with locale code + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", true, ExpectedResult = true)] + [TestCase("Data/Achievements/Path.fr-FR", "Data/Achievements", false, ExpectedResult = false)] + public bool StartsWith_Subfolder(string mainAssetName, string otherAssetName, bool allowSubfolder) + { + // arrange + mainAssetName = PathUtilities.NormalizeAssetName(mainAssetName); + + // act + AssetName name = AssetName.Parse(mainAssetName, _ => null); + + // assert value is the same for any combination of options + bool result = name.StartsWith(otherAssetName, allowPartialWord: true, allowSubfolder: allowSubfolder); + name.StartsWith(otherAssetName, allowPartialWord: false, allowSubfolder: allowSubfolder) + .Should().Be(result, "specifying allowPartialWord should have no effect for these inputs"); + + // assert value + return result; + } + + + /**** + ** GetHashCode + ****/ + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates the same hash code for two asset names which differ only by capitalization.")] + public void GetHashCode_IsCaseInsensitive() + { + // arrange + string left = "data/ACHIEVEMENTS"; + string right = "DATA/achievements"; + + // act + int leftHash = AssetName.Parse(left, _ => null).GetHashCode(); + int rightHash = AssetName.Parse(right, _ => null).GetHashCode(); + + // assert + leftHash.Should().Be(rightHash, "two asset names which differ only by capitalization should produce the same hash code"); + } + + [Test(Description = $"Assert that {nameof(AssetName.GetHashCode)} generates few hash code collisions for an arbitrary set of asset names.")] + public void GetHashCode_HasFewCollisions() + { + // generate list of names + List<string> names = new(); + { + Random random = new(); + string characters = "abcdefghijklmnopqrstuvwxyz1234567890/"; + + while (names.Count < 1000) + { + char[] name = new char[random.Next(5, 20)]; + for (int i = 0; i < name.Length; i++) + name[i] = characters[random.Next(0, characters.Length)]; + + names.Add(new string(name)); + } + } + + // get distinct hash codes + HashSet<int> hashCodes = new(); + foreach (string name in names) + hashCodes.Add(AssetName.Parse(name, _ => null).GetHashCode()); + + // assert a collision frequency under 0.1% + float collisionFrequency = 1 - (hashCodes.Count / (names.Count * 1f)); + collisionFrequency.Should().BeLessOrEqualTo(0.001f, "hash codes should be relatively distinct with a collision rate under 0.1% for a small sample set"); + } + } +} diff --git a/src/SMAPI.Tests/Core/InterfaceProxyTests.cs b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs new file mode 100644 index 00000000..6be97526 --- /dev/null +++ b/src/SMAPI.Tests/Core/InterfaceProxyTests.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using NUnit.Framework; +using SMAPI.Tests.ModApiConsumer; +using SMAPI.Tests.ModApiConsumer.Interfaces; +using SMAPI.Tests.ModApiProvider; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Utilities; + +namespace SMAPI.Tests.Core +{ + /// <summary>Unit tests for <see cref="InterfaceProxyFactory"/>.</summary> + [TestFixture] + internal class InterfaceProxyTests + { + /********* + ** Fields + *********/ + /// <summary>The mod ID providing an API.</summary> + private readonly string FromModId = "From.ModId"; + + /// <summary>The mod ID consuming an API.</summary> + private readonly string ToModId = "From.ModId"; + + /// <summary>The random number generator with which to create sample values.</summary> + private readonly Random Random = new(); + + /// <summary>Sample user inputs for season names.</summary> + private static readonly IInterfaceProxyFactory[] ProxyFactories = { + new InterfaceProxyFactory(), + new OriginalInterfaceProxyFactory() + }; + + + /********* + ** Unit tests + *********/ + /**** + ** Events + ****/ + /// <summary>Assert that an event field can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_EventField([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + new ApiConsumer().UseEventField(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } + + /// <summary>Assert that an event property can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_EventProperty([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + new ApiConsumer().UseEventProperty(proxy, out Func<(int timesCalled, int lastValue)> getValues); + providerMod.RaiseEvent(expectedValue); + (int timesCalled, int lastValue) = getValues(); + + // assert + timesCalled.Should().Be(1, "Expected the proxied event to be raised once."); + lastValue.Should().Be(expectedValue, "The proxy received a different event argument than the implementation raised."); + } + + /**** + ** Properties + ****/ + /// <summary>Assert that properties can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + /// <param name="setVia">Whether to set the properties through the <c>provider mod</c> or <c>proxy interface</c>.</param> + [Test] + public void CanProxy_Properties([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory, [Values("set via provider mod", "set via proxy interface")] string setVia) + { + // arrange + ProviderMod providerMod = new(); + object implementation = providerMod.GetModApi(); + int expectedNumber = this.Random.Next(); + int expectedObject = this.Random.Next(); + string expectedListValue = this.GetRandomString(); + string expectedListWithInterfaceValue = this.GetRandomString(); + string expectedDictionaryKey = this.GetRandomString(); + string expectedDictionaryListValue = this.GetRandomString(); + string expectedInheritedString = this.GetRandomString(); + BindingFlags expectedEnum = BindingFlags.Instance | BindingFlags.Public; + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + switch (setVia) + { + case "set via provider mod": + providerMod.SetPropertyValues( + number: expectedNumber, + obj: expectedObject, + listValue: expectedListValue, + listWithInterfaceValue: expectedListWithInterfaceValue, + dictionaryKey: expectedDictionaryKey, + dictionaryListValue: expectedDictionaryListValue, + enumValue: expectedEnum, + inheritedValue: expectedInheritedString + ); + break; + + case "set via proxy interface": + proxy.NumberProperty = expectedNumber; + proxy.ObjectProperty = expectedObject; + proxy.ListProperty = new() { expectedListValue }; + proxy.ListPropertyWithInterface = new List<string> { expectedListWithInterfaceValue }; + proxy.GenericsProperty = new Dictionary<string, IList<string>> + { + [expectedDictionaryKey] = new List<string> { expectedDictionaryListValue } + }; + proxy.EnumProperty = expectedEnum; + proxy.InheritedProperty = expectedInheritedString; + break; + + default: + throw new InvalidOperationException($"Invalid 'set via' option '{setVia}."); + } + + // assert number + this + .GetPropertyValue(implementation, nameof(proxy.NumberProperty)) + .Should().Be(expectedNumber); + proxy.NumberProperty + .Should().Be(expectedNumber); + + // assert object + this + .GetPropertyValue(implementation, nameof(proxy.ObjectProperty)) + .Should().Be(expectedObject); + proxy.ObjectProperty + .Should().Be(expectedObject); + + // assert list + (this.GetPropertyValue(implementation, nameof(proxy.ListProperty)) as IList<string>) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + proxy.ListProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListValue); + + // assert list with interface + (this.GetPropertyValue(implementation, nameof(proxy.ListPropertyWithInterface)) as IList<string>) + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + proxy.ListPropertyWithInterface + .Should().NotBeNull() + .And.HaveCount(1) + .And.BeEquivalentTo(expectedListWithInterfaceValue); + + // assert generics + (this.GetPropertyValue(implementation, nameof(proxy.GenericsProperty)) as IDictionary<string, IList<string>>) + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + proxy.GenericsProperty + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedDictionaryKey).WhoseValue.Should().BeEquivalentTo(expectedDictionaryListValue); + + // assert enum + this + .GetPropertyValue(implementation, nameof(proxy.EnumProperty)) + .Should().Be(expectedEnum); + proxy.EnumProperty + .Should().Be(expectedEnum); + + // assert getter + this + .GetPropertyValue(implementation, nameof(proxy.GetterProperty)) + .Should().Be(42); + proxy.GetterProperty + .Should().Be(42); + + // assert inherited methods + this + .GetPropertyValue(implementation, nameof(proxy.InheritedProperty)) + .Should().Be(expectedInheritedString); + proxy.InheritedProperty + .Should().Be(expectedInheritedString); + } + + /// <summary>Assert that a simple method with no return value can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_Void([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + proxy.GetNothing(); + } + + /// <summary>Assert that a simple int method can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_Int([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + int expectedValue = this.Random.Next(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + int actualValue = proxy.GetInt(expectedValue); + + // assert + actualValue.Should().Be(expectedValue); + } + + /// <summary>Assert that a simple object method can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_Object([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + object expectedValue = new(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + object actualValue = proxy.GetObject(expectedValue); + + // assert + actualValue.Should().BeSameAs(expectedValue); + } + + /// <summary>Assert that a simple list method can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_List([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IList<string> actualValue = proxy.GetList(expectedValue); + + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } + + /// <summary>Assert that a simple list with interface method can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_ListWithInterface([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IList<string> actualValue = proxy.GetListWithInterface(expectedValue); + + // assert + actualValue.Should().BeEquivalentTo(expectedValue); + } + + /// <summary>Assert that a simple method which returns generic types can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + public void CanProxy_SimpleMethod_GenericTypes([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + string expectedKey = this.GetRandomString(); + string expectedValue = this.GetRandomString(); + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + IDictionary<string, IList<string>> actualValue = proxy.GetGenerics(expectedKey, expectedValue); + + // assert + actualValue + .Should().NotBeNull() + .And.HaveCount(1) + .And.ContainKey(expectedKey).WhoseValue.Should().BeEquivalentTo(expectedValue); + } + + /// <summary>Assert that a simple lambda method can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + [SuppressMessage("ReSharper", "ConvertToLocalFunction")] + public void CanProxy_SimpleMethod_Lambda([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + Func<string, string> expectedValue = _ => "test"; + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + object actualValue = proxy.GetObject(expectedValue); + + // assert + actualValue.Should().BeSameAs(expectedValue); + } + + /// <summary>Assert that a method with out parameters can be proxied correctly.</summary> + /// <param name="proxyFactory">The proxy factory to test.</param> + [Test] + [SuppressMessage("ReSharper", "ConvertToLocalFunction")] + public void CanProxy_Method_OutParameters([ValueSource(nameof(InterfaceProxyTests.ProxyFactories))] IInterfaceProxyFactory proxyFactory) + { + // arrange + object implementation = new ProviderMod().GetModApi(); + const int expectedNumber = 42; + + // act + ISimpleApi proxy = this.GetProxy(proxyFactory, implementation); + bool result = proxy.TryGetOutParameter( + inputNumber: expectedNumber, + + out int outNumber, + out string outString, + out PerScreen<int> outReference, + out IDictionary<int, PerScreen<int>> outComplexType + ); + + // assert + result.Should().BeTrue(); + + outNumber.Should().Be(expectedNumber); + + outString.Should().Be(expectedNumber.ToString()); + + outReference.Should().NotBeNull(); + outReference.Value.Should().Be(expectedNumber); + + outComplexType.Should().NotBeNull(); + outComplexType.Count.Should().Be(1); + outComplexType.Keys.First().Should().Be(expectedNumber); + outComplexType.Values.First().Should().NotBeNull(); + outComplexType.Values.First().Value.Should().Be(expectedNumber); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get a property value from an instance.</summary> + /// <param name="parent">The instance whose property to read.</param> + /// <param name="name">The property name.</param> + private object? GetPropertyValue(object parent, string name) + { + if (parent is null) + throw new ArgumentNullException(nameof(parent)); + + Type type = parent.GetType(); + PropertyInfo? property = type.GetProperty(name); + if (property is null) + throw new InvalidOperationException($"The '{type.FullName}' type has no public property named '{name}'."); + + return property.GetValue(parent); + } + + /// <summary>Get a random test string.</summary> + private string GetRandomString() + { + return this.Random.Next().ToString(); + } + + /// <summary>Get a proxy API instance.</summary> + /// <param name="proxyFactory">The proxy factory to use.</param> + /// <param name="implementation">The underlying API instance.</param> + private ISimpleApi GetProxy(IInterfaceProxyFactory proxyFactory, object implementation) + { + return proxyFactory.CreateProxy<ISimpleApi>(implementation, this.FromModId, this.ToModId); + } + } +} diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index da3446bb..6b2746f5 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -38,6 +38,9 @@ namespace SMAPI.Tests.Core // assert Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead."); + + // cleanup + Directory.Delete(rootFolder, recursive: true); } [Test(Description = "Assert that the resolver correctly returns a failed metadata if there's an empty mod folder.")] @@ -50,12 +53,15 @@ namespace SMAPI.Tests.Core // act IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray(); - IModMetadata mod = mods.FirstOrDefault(); + IModMetadata? mod = mods.FirstOrDefault(); // assert Assert.AreEqual(1, mods.Length, 0, $"Expected to find one manifest, found {mods.Length} instead."); - Assert.AreEqual(ModMetadataStatus.Failed, mod.Status, "The mod metadata was not marked failed."); + Assert.AreEqual(ModMetadataStatus.Failed, mod!.Status, "The mod metadata was not marked failed."); Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); + + // cleanup + Directory.Delete(rootFolder, recursive: true); } [Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")] @@ -89,12 +95,12 @@ namespace SMAPI.Tests.Core // act IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray(); - IModMetadata mod = mods.FirstOrDefault(); + IModMetadata? mod = mods.FirstOrDefault(); // assert Assert.AreEqual(1, mods.Length, 0, "Expected to find one manifest."); Assert.IsNotNull(mod, "The loaded manifest shouldn't be null."); - Assert.AreEqual(null, mod.DataRecord, "The data record should be null since we didn't provide one."); + Assert.AreEqual(null, mod!.DataRecord, "The data record should be null since we didn't provide one."); Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match."); Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded."); Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match."); @@ -115,6 +121,9 @@ namespace SMAPI.Tests.Core Assert.IsNotNull(mod.Manifest.Dependencies, "The dependencies field should not be null."); Assert.AreEqual(1, mod.Manifest.Dependencies.Length, "The dependencies field should contain one value."); Assert.AreEqual(originalDependency[nameof(IManifestDependency.UniqueID)], mod.Manifest.Dependencies[0].UniqueID, "The first dependency's unique ID doesn't match."); + + // cleanup + Directory.Delete(rootFolder, recursive: true); } /**** @@ -123,7 +132,7 @@ namespace SMAPI.Tests.Core [Test(Description = "Assert that validation doesn't fail if there are no mods installed.")] public void ValidateManifests_NoMods_DoesNothing() { - new ModResolver().ValidateManifests(new ModMetadata[0], apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(Array.Empty<ModMetadata>(), apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false); } [Test(Description = "Assert that validation skips manifests that have already failed without calling any other properties.")] @@ -134,7 +143,7 @@ namespace SMAPI.Tests.Core mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false); // assert mock.VerifyGet(p => p.Status, Times.Once, "The validation did not check the manifest status."); @@ -144,14 +153,14 @@ namespace SMAPI.Tests.Core public void ValidateManifests_ModStatus_AssumeBroken_Fails() { // arrange - Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); - this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields + Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true); + mock.Setup(p => p.DataRecord).Returns(() => new ModDataRecordVersionedFields(this.GetModDataRecord()) { Status = ModStatus.AssumeBroken }); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); @@ -161,12 +170,11 @@ namespace SMAPI.Tests.Core public void ValidateManifests_MinimumApiVersion_Fails() { // arrange - Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock<IModMetadata> mock = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true); mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1")); - this.SetupMetadataForValidation(mock); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); @@ -176,32 +184,33 @@ namespace SMAPI.Tests.Core public void ValidateManifests_MissingEntryDLL_Fails() { // arrange - Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true); - this.SetupMetadataForValidation(mock); + string directoryPath = this.GetTempFolderPath(); + Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true, directoryPath: directoryPath); + Directory.CreateDirectory(directoryPath); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null); // assert mock.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the metadata."); + + // cleanup + Directory.Delete(directoryPath); } [Test(Description = "Assert that validation fails when multiple mods have the same unique ID.")] public void ValidateManifests_DuplicateUniqueID_Fails() { // arrange - Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true); + Mock<IModMetadata> modA = this.GetMetadata("Mod A", Array.Empty<string>(), allowStatusChange: true); Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true); - Mock<IModMetadata> modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false); - foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC }) - this.SetupMetadataForValidation(mod); // act - new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { modA.Object, modB.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null, validateFilesExist: false); // assert - modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the first mod with a unique ID."); - modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, It.IsAny<ModFailReason>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once, "The validation did not fail the second mod with a unique ID."); + modA.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the first mod with a unique ID."); + modB.Verify(p => p.SetStatus(ModMetadataStatus.Failed, ModFailReason.Duplicate, It.IsAny<string>(), It.IsAny<string>()), Times.AtLeastOnce, "The validation did not fail the second mod with a unique ID."); } [Test(Description = "Assert that validation fails when the manifest references a DLL that does not exist.")] @@ -213,20 +222,23 @@ namespace SMAPI.Tests.Core // create DLL string modFolder = Path.Combine(this.GetTempFolderPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(modFolder); - File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); + File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll!), ""); // arrange - Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); + Mock<IModMetadata> mock = new(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mock.Setup(p => p.DataRecord).Returns(() => null); + mock.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); mock.Setup(p => p.Manifest).Returns(manifest); mock.Setup(p => p.DirectoryPath).Returns(modFolder); // act - new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: key => null); + new ModResolver().ValidateManifests(new[] { mock.Object }, apiVersion: new SemanticVersion("1.0"), getUpdateUrl: _ => null); // assert // if Moq doesn't throw a method-not-setup exception, the validation didn't override the status. + + // cleanup + Directory.Delete(modFolder, recursive: true); } /**** @@ -236,7 +248,7 @@ namespace SMAPI.Tests.Core public void ProcessDependencies_NoMods_DoesNothing() { // act - IModMetadata[] mods = new ModResolver().ProcessDependencies(new IModMetadata[0], new ModDatabase()).ToArray(); + IModMetadata[] mods = new ModResolver().ProcessDependencies(Array.Empty<IModMetadata>(), new ModDatabase()).ToArray(); // assert Assert.AreEqual(0, mods.Length, 0, "Expected to get an empty list of mods."); @@ -265,7 +277,7 @@ namespace SMAPI.Tests.Core public void ProcessDependencies_Skips_Failed() { // arrange - Mock<IModMetadata> mock = new Mock<IModMetadata>(MockBehavior.Strict); + Mock<IModMetadata> mock = new(MockBehavior.Strict); mock.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); // act @@ -380,7 +392,7 @@ namespace SMAPI.Tests.Core Mock<IModMetadata> modA = this.GetMetadata("Mod A"); Mock<IModMetadata> modB = this.GetMetadata("Mod B", dependencies: new[] { "Mod A" }); Mock<IModMetadata> modC = this.GetMetadata("Mod C", dependencies: new[] { "Mod B" }, allowStatusChange: true); - Mock<IModMetadata> modD = new Mock<IModMetadata>(MockBehavior.Strict); + Mock<IModMetadata> modD = new(MockBehavior.Strict); modD.Setup(p => p.Manifest).Returns<IManifest>(null); modD.Setup(p => p.Status).Returns(ModMetadataStatus.Failed); @@ -478,21 +490,20 @@ namespace SMAPI.Tests.Core /// <param name="contentPackForID">The <see cref="IManifest.ContentPackFor"/> value.</param> /// <param name="minimumApiVersion">The <see cref="IManifest.MinimumApiVersion"/> value.</param> /// <param name="dependencies">The <see cref="IManifest.Dependencies"/> value.</param> - private Manifest GetManifest(string id = null, string name = null, string version = null, string entryDll = null, string contentPackForID = null, string minimumApiVersion = null, IManifestDependency[] dependencies = null) + private Manifest GetManifest(string? id = null, string? name = null, string? version = null, string? entryDll = null, string? contentPackForID = null, string? minimumApiVersion = null, IManifestDependency[]? dependencies = null) { - return new Manifest - { - UniqueID = id ?? $"{Sample.String()}.{Sample.String()}", - Name = name ?? id ?? Sample.String(), - Author = Sample.String(), - Description = Sample.String(), - Version = version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), - EntryDll = entryDll ?? $"{Sample.String()}.dll", - ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null, - MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, - Dependencies = dependencies ?? new IManifestDependency[0], - UpdateKeys = new string[0] - }; + return new Manifest( + uniqueId: id ?? $"{Sample.String()}.{Sample.String()}", + name: name ?? id ?? Sample.String(), + author: Sample.String(), + description: Sample.String(), + version: version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()), + entryDll: entryDll ?? $"{Sample.String()}.dll", + contentPackFor: contentPackForID != null ? new ManifestContentPackFor(contentPackForID, null) : null, + minimumApiVersion: minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null, + dependencies: dependencies ?? Array.Empty<IManifestDependency>(), + updateKeys: Array.Empty<string>() + ); } /// <summary>Get a randomized basic manifest.</summary> @@ -508,21 +519,27 @@ namespace SMAPI.Tests.Core /// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false) { - IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray()); + IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null as ISemanticVersion)).ToArray()); return this.GetMetadata(manifest, allowStatusChange); } /// <summary>Get a randomized basic manifest.</summary> /// <param name="manifest">The mod manifest.</param> /// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> - private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false) + /// <param name="directoryPath">The directory path the mod metadata should be pointed at, or <c>null</c> to generate a fake path.</param> + private Mock<IModMetadata> GetMetadata(IManifest manifest, bool allowStatusChange = false, string? directoryPath = null) { - Mock<IModMetadata> mod = new Mock<IModMetadata>(MockBehavior.Strict); - mod.Setup(p => p.DataRecord).Returns(() => null); + directoryPath ??= this.GetTempFolderPath(); + + Mock<IModMetadata> mod = new(MockBehavior.Strict); + mod.Setup(p => p.DataRecord).Returns(this.GetModDataRecordVersionedFields()); mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); mod.Setup(p => p.DisplayName).Returns(manifest.UniqueID); + mod.Setup(p => p.DirectoryPath).Returns(directoryPath); mod.Setup(p => p.Manifest).Returns(manifest); mod.Setup(p => p.HasID(It.IsAny<string>())).Returns((string id) => manifest.UniqueID == id); + mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>()); + mod.Setup(p => p.GetRelativePathWithRoot()).Returns(directoryPath); if (allowStatusChange) { mod @@ -533,17 +550,16 @@ namespace SMAPI.Tests.Core return mod; } - /// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary> - /// <param name="mod">The mock mod metadata.</param> - /// <param name="modRecord">The extra metadata about the mod from SMAPI's internal data (if any).</param> - private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModDataRecordVersionedFields modRecord = null) + /// <summary>Generate a default mod data record.</summary> + private ModDataRecord GetModDataRecord() { - mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found); - mod.Setup(p => p.DataRecord).Returns(() => null); - mod.Setup(p => p.Manifest).Returns(this.GetManifest()); - mod.Setup(p => p.DirectoryPath).Returns(Path.GetTempPath()); - mod.Setup(p => p.DataRecord).Returns(modRecord); - mod.Setup(p => p.GetUpdateKeys(It.IsAny<bool>())).Returns(Enumerable.Empty<UpdateKey>()); + return new("Default Display Name", new ModDataModel("Sample ID", null, ModWarning.None)); + } + + /// <summary>Generate a default mod data versioned fields instance.</summary> + private ModDataRecordVersionedFields GetModDataRecordVersionedFields() + { + return new ModDataRecordVersionedFields(this.GetModDataRecord()); } } } diff --git a/src/SMAPI.Tests/Core/TranslationTests.cs b/src/SMAPI.Tests/Core/TranslationTests.cs index 457f9fad..a52df607 100644 --- a/src/SMAPI.Tests/Core/TranslationTests.cs +++ b/src/SMAPI.Tests/Core/TranslationTests.cs @@ -1,9 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using NUnit.Framework; using StardewModdingAPI; +using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModHelpers; +using StardewModdingAPI.Framework.ModLoading; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewValley; namespace SMAPI.Tests.Core @@ -16,7 +21,7 @@ namespace SMAPI.Tests.Core ** Data *********/ /// <summary>Sample translation text for unit tests.</summary> - public static string[] Samples = { null, "", " ", "boop", " boop " }; + public static string?[] Samples = { null, "", " ", "boop", " boop " }; /********* @@ -32,15 +37,15 @@ namespace SMAPI.Tests.Core var data = new Dictionary<string, IDictionary<string, string>>(); // act - ITranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + ITranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); Translation translation = helper.Get("key"); - Translation[] translationList = helper.GetTranslations()?.ToArray(); + Translation[]? translationList = helper.GetTranslations()?.ToArray(); // assert Assert.AreEqual("en", helper.Locale, "The locale doesn't match the input value."); Assert.AreEqual(LocalizedContentManager.LanguageCode.en, helper.LocaleEnum, "The locale enum doesn't match the input value."); Assert.IsNotNull(translationList, "The full list of translations is unexpectedly null."); - Assert.AreEqual(0, translationList.Length, "The full list of translations is unexpectedly not empty."); + Assert.AreEqual(0, translationList!.Length, "The full list of translations is unexpectedly not empty."); Assert.IsNotNull(translation, "The translation helper unexpectedly returned a null translation."); Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value."); @@ -54,8 +59,8 @@ namespace SMAPI.Tests.Core var expected = this.GetExpectedTranslations(); // act - var actual = new Dictionary<string, Translation[]>(); - TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + var actual = new Dictionary<string, Translation[]?>(); + TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); @@ -79,7 +84,7 @@ namespace SMAPI.Tests.Core // act var actual = new Dictionary<string, Translation[]>(); - TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + TranslationHelper helper = new TranslationHelper(this.CreateModMetadata(), "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); @@ -107,16 +112,16 @@ namespace SMAPI.Tests.Core [TestCase(" ", ExpectedResult = true)] [TestCase("boop", ExpectedResult = true)] [TestCase(" boop ", ExpectedResult = true)] - public bool Translation_HasValue(string text) + public bool Translation_HasValue(string? text) { return new Translation("pt-BR", "key", text).HasValue(); } [Test(Description = "Assert that the translation's ToString method returns the expected text for various inputs.")] - public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string text) + public void Translation_ToString([ValueSource(nameof(TranslationTests.Samples))] string? text) { // act - Translation translation = new Translation("pt-BR", "key", text); + Translation translation = new("pt-BR", "key", text); // assert if (translation.HasValue()) @@ -126,20 +131,20 @@ namespace SMAPI.Tests.Core } [Test(Description = "Assert that the translation's implicit string conversion returns the expected text for various inputs.")] - public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text) + public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string? text) { // act - Translation translation = new Translation("pt-BR", "key", text); + Translation translation = new("pt-BR", "key", text); // assert if (translation.HasValue()) - Assert.AreEqual(text, (string)translation, "The translation returned an unexpected value given a valid input."); + Assert.AreEqual(text, (string?)translation, "The translation returned an unexpected value given a valid input."); else - Assert.AreEqual(this.GetPlaceholderText("key"), (string)translation, "The translation returned an unexpected value given a null or empty input."); + Assert.AreEqual(this.GetPlaceholderText("key"), (string?)translation, "The translation returned an unexpected value given a null or empty input."); } [Test(Description = "Assert that the translation returns the expected text given a use-placeholder setting.")] - public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text) + public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string? text) { // act Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value); @@ -154,7 +159,7 @@ namespace SMAPI.Tests.Core } [Test(Description = "Assert that the translation returns the expected text after setting the default.")] - public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default) + public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string? text, [ValueSource(nameof(TranslationTests.Samples))] string? @default) { // act Translation translation = new Translation("pt-BR", "key", text).Default(@default); @@ -182,7 +187,7 @@ namespace SMAPI.Tests.Core string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; // act - Translation translation = new Translation("pt-BR", "key", input); + Translation translation = new("pt-BR", "key", input); switch (structure) { case "anonymous object": @@ -190,7 +195,7 @@ namespace SMAPI.Tests.Core break; case "class": - translation = translation.Tokens(new TokenModel { Start = start, Middle = middle, End = end }); + translation = translation.Tokens(new TokenModel(start, middle, end)); break; case "IDictionary<string, object>": @@ -324,21 +329,63 @@ namespace SMAPI.Tests.Core return string.Format(Translation.PlaceholderText, key); } + /// <summary>Create a fake mod manifest.</summary> + private IModMetadata CreateModMetadata() + { + string id = $"smapi.unit-tests.fake-mod-{Guid.NewGuid():N}"; + + string tempPath = Path.Combine(Path.GetTempPath(), id); + return new ModMetadata( + displayName: "Mod Display Name", + directoryPath: tempPath, + rootPath: tempPath, + manifest: new Manifest( + uniqueID: id, + name: "Mod Name", + author: "Mod Author", + description: "Mod Description", + version: new SemanticVersion(1, 0, 0) + ), + dataRecord: null, + isIgnored: false + ); + } + /********* ** Test models *********/ /// <summary>A model used to test token support.</summary> + [SuppressMessage("ReSharper", "NotAccessedField.Local", Justification = "Used dynamically via translation helper.")] + [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local", Justification = "Used dynamically via translation helper.")] private class TokenModel { + /********* + ** Accessors + *********/ /// <summary>A sample token property.</summary> - public string Start { get; set; } + public string Start { get; } /// <summary>A sample token property.</summary> - public string Middle { get; set; } + public string Middle { get; } /// <summary>A sample token field.</summary> public string End; + + + /********* + ** public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="start">A sample token property.</param> + /// <param name="middle">A sample token field.</param> + /// <param name="end">A sample token property.</param> + public TokenModel(string start, string middle, string end) + { + this.Start = start; + this.Middle = middle; + this.End = end; + } } } } diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj index 8329b2e1..67997b30 100644 --- a/src/SMAPI.Tests/SMAPI.Tests.csproj +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -1,21 +1,20 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <AssemblyName>SMAPI.Tests</AssemblyName> - <RootNamespace>SMAPI.Tests</RootNamespace> <TargetFramework>net5.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <LangVersion>latest</LangVersion> </PropertyGroup> <Import Project="..\..\build\common.targets" /> <ItemGroup> + <ProjectReference Include="..\SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj" /> + <ProjectReference Include="..\SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj" /> <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> <ProjectReference Include="..\SMAPI\SMAPI.csproj" /> </ItemGroup> <ItemGroup> + <PackageReference Include="FluentAssertions" Version="6.5.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Moq" Version="4.16.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> diff --git a/src/SMAPI.Tests/Sample.cs b/src/SMAPI.Tests/Sample.cs index f4f0d88e..9587a100 100644 --- a/src/SMAPI.Tests/Sample.cs +++ b/src/SMAPI.Tests/Sample.cs @@ -9,7 +9,7 @@ namespace SMAPI.Tests ** Fields *********/ /// <summary>A random number generator.</summary> - private static readonly Random Random = new Random(); + private static readonly Random Random = new(); /********* diff --git a/src/SMAPI.Tests/Utilities/KeybindListTests.cs b/src/SMAPI.Tests/Utilities/KeybindListTests.cs index 0bd6ec17..c4c086de 100644 --- a/src/SMAPI.Tests/Utilities/KeybindListTests.cs +++ b/src/SMAPI.Tests/Utilities/KeybindListTests.cs @@ -21,12 +21,12 @@ namespace SMAPI.Tests.Utilities public void TryParse_SimpleValue(SButton button) { // act - bool success = KeybindList.TryParse($"{button}", out KeybindList parsed, out string[] errors); + bool success = KeybindList.TryParse($"{button}", out KeybindList? parsed, out string[] errors); // assert Assert.IsTrue(success, "Parsing unexpectedly failed."); Assert.IsNotNull(parsed, "The parsed result should not be null."); - Assert.AreEqual(parsed.ToString(), $"{button}"); + Assert.AreEqual(parsed!.ToString(), $"{button}"); Assert.IsNotNull(errors, message: "The errors should never be null."); Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); } @@ -44,17 +44,17 @@ namespace SMAPI.Tests.Utilities [TestCase(",", ExpectedResult = "None")] [TestCase("A,", ExpectedResult = "A")] [TestCase(",A", ExpectedResult = "A")] - public string TryParse_MultiValues(string input) + public string TryParse_MultiValues(string? input) { // act - bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); // assert Assert.IsTrue(success, "Parsing unexpectedly failed."); Assert.IsNotNull(parsed, "The parsed result should not be null."); Assert.IsNotNull(errors, message: "The errors should never be null."); Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); - return parsed.ToString(); + return parsed!.ToString(); } /// <summary>Assert invalid values are rejected.</summary> @@ -67,7 +67,7 @@ namespace SMAPI.Tests.Utilities public void TryParse_InvalidValues(string input, string expectedError) { // act - bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); // assert Assert.IsFalse(success, "Parsing unexpectedly succeeded."); @@ -98,13 +98,15 @@ namespace SMAPI.Tests.Utilities public SButtonState GetState(string input, string stateMap) { // act - bool success = KeybindList.TryParse(input, out KeybindList parsed, out string[] errors); + bool success = KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors); if (success && parsed?.Keybinds != null) { - foreach (var keybind in parsed.Keybinds) + foreach (Keybind? keybind in parsed.Keybinds) + { #pragma warning disable 618 // method is marked obsolete because it should only be used in unit tests keybind.GetButtonState = key => this.GetStateFromMap(key, stateMap); #pragma warning restore 618 + } } // assert @@ -112,7 +114,7 @@ namespace SMAPI.Tests.Utilities Assert.IsNotNull(parsed, "The parsed result should not be null."); Assert.IsNotNull(errors, message: "The errors should never be null."); Assert.IsEmpty(errors, message: "The input bindings incorrectly reported errors."); - return parsed.GetState(); + return parsed!.GetState(); } diff --git a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs index ab4c2618..3219d89d 100644 --- a/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs +++ b/src/SMAPI.Tests/Utilities/PathUtilitiesTests.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.IO; using NUnit.Framework; using StardewModdingAPI.Toolkit.Utilities; @@ -6,6 +7,7 @@ namespace SMAPI.Tests.Utilities { /// <summary>Unit tests for <see cref="PathUtilities"/>.</summary> [TestFixture] + [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are standard game install paths.")] internal class PathUtilitiesTests { /********* @@ -14,136 +16,125 @@ namespace SMAPI.Tests.Utilities /// <summary>Sample paths used in unit tests.</summary> public static readonly SamplePath[] SamplePaths = { // Windows absolute path - new SamplePath - { - OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + new( + OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", - Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, + Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley" }, - NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", - NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" - }, + NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley" + ), // Windows absolute path (with trailing slash) - new SamplePath - { - OriginalPath = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + new( + OriginalPath: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", - Segments = new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3 = new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, + Segments: new[] { "C:", "Program Files (x86)", "Steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "C:", "Program Files (x86)", @"Steam\steamapps\common\Stardew Valley\" }, - NormalizedOnWindows = @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", - NormalizedOnUnix = @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" - }, + NormalizedOnWindows: @"C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix: @"C:/Program Files (x86)/Steam/steamapps/common/Stardew Valley/" + ), // Windows relative path - new SamplePath - { - OriginalPath = @"Content\Characters\Dialogue\Abigail", + new( + OriginalPath: @"Content\Characters\Dialogue\Abigail", - Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, - SegmentsLimit3 = new [] { "Content", "Characters", @"Dialogue\Abigail" }, + Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3: new [] { "Content", "Characters", @"Dialogue\Abigail" }, - NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", - NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" - }, + NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" + ), // Windows relative path (with directory climbing) - new SamplePath - { - OriginalPath = @"..\..\Content", + new( + OriginalPath: @"..\..\Content", - Segments = new [] { "..", "..", "Content" }, - SegmentsLimit3 = new [] { "..", "..", "Content" }, + Segments: new [] { "..", "..", "Content" }, + SegmentsLimit3: new [] { "..", "..", "Content" }, - NormalizedOnWindows = @"..\..\Content", - NormalizedOnUnix = @"../../Content" - }, + NormalizedOnWindows: @"..\..\Content", + NormalizedOnUnix: @"../../Content" + ), // Windows UNC path - new SamplePath - { - OriginalPath = @"\\unc\path", + new( + OriginalPath: @"\\unc\path", - Segments = new [] { "unc", "path" }, - SegmentsLimit3 = new [] { "unc", "path" }, + Segments: new [] { "unc", "path" }, + SegmentsLimit3: new [] { "unc", "path" }, - NormalizedOnWindows = @"\\unc\path", - NormalizedOnUnix = "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value - }, + NormalizedOnWindows: @"\\unc\path", + NormalizedOnUnix: "/unc/path" // there's no good way to normalize this on Unix since UNC paths aren't supported; path normalization is meant for asset names anyway, so this test only ensures it returns some sort of sane value + ), // Linux absolute path - new SamplePath - { - OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley", + new( + OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley", - Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, + Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley" }, - NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley", - NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley" - }, + NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley" + ), // Linux absolute path (with trailing slash) - new SamplePath - { - OriginalPath = @"/home/.steam/steam/steamapps/common/Stardew Valley/", + new( + OriginalPath: @"/home/.steam/steam/steamapps/common/Stardew Valley/", - Segments = new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3 = new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, + Segments: new [] { "home", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "home", ".steam", "steam/steamapps/common/Stardew Valley/" }, - NormalizedOnWindows = @"\home\.steam\steam\steamapps\common\Stardew Valley\", - NormalizedOnUnix = @"/home/.steam/steam/steamapps/common/Stardew Valley/" - }, + NormalizedOnWindows: @"\home\.steam\steam\steamapps\common\Stardew Valley\", + NormalizedOnUnix: @"/home/.steam/steam/steamapps/common/Stardew Valley/" + ), // Linux absolute path (with ~) - new SamplePath - { - OriginalPath = @"~/.steam/steam/steamapps/common/Stardew Valley", + new( + OriginalPath: @"~/.steam/steam/steamapps/common/Stardew Valley", - Segments = new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, - SegmentsLimit3 = new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, + Segments: new [] { "~", ".steam", "steam", "steamapps", "common", "Stardew Valley" }, + SegmentsLimit3: new [] { "~", ".steam", "steam/steamapps/common/Stardew Valley" }, - NormalizedOnWindows = @"~\.steam\steam\steamapps\common\Stardew Valley", - NormalizedOnUnix = @"~/.steam/steam/steamapps/common/Stardew Valley" - }, + NormalizedOnWindows: @"~\.steam\steam\steamapps\common\Stardew Valley", + NormalizedOnUnix: @"~/.steam/steam/steamapps/common/Stardew Valley" + ), // Linux relative path - new SamplePath - { - OriginalPath = @"Content/Characters/Dialogue/Abigail", + new( + OriginalPath: @"Content/Characters/Dialogue/Abigail", - Segments = new [] { "Content", "Characters", "Dialogue", "Abigail" }, - SegmentsLimit3 = new [] { "Content", "Characters", "Dialogue/Abigail" }, + Segments: new [] { "Content", "Characters", "Dialogue", "Abigail" }, + SegmentsLimit3: new [] { "Content", "Characters", "Dialogue/Abigail" }, - NormalizedOnWindows = @"Content\Characters\Dialogue\Abigail", - NormalizedOnUnix = @"Content/Characters/Dialogue/Abigail" - }, + NormalizedOnWindows: @"Content\Characters\Dialogue\Abigail", + NormalizedOnUnix: @"Content/Characters/Dialogue/Abigail" + ), // Linux relative path (with directory climbing) - new SamplePath - { - OriginalPath = @"../../Content", + new( + OriginalPath: @"../../Content", - Segments = new [] { "..", "..", "Content" }, - SegmentsLimit3 = new [] { "..", "..", "Content" }, + Segments: new [] { "..", "..", "Content" }, + SegmentsLimit3: new [] { "..", "..", "Content" }, - NormalizedOnWindows = @"..\..\Content", - NormalizedOnUnix = @"../../Content" - }, + NormalizedOnWindows: @"..\..\Content", + NormalizedOnUnix: @"../../Content" + ), // Mixed directory separators - new SamplePath - { - OriginalPath = @"C:\some/mixed\path/separators", + new( + OriginalPath: @"C:\some/mixed\path/separators", - Segments = new [] { "C:", "some", "mixed", "path", "separators" }, - SegmentsLimit3 = new [] { "C:", "some", @"mixed\path/separators" }, + Segments: new [] { "C:", "some", "mixed", "path", "separators" }, + SegmentsLimit3: new [] { "C:", "some", @"mixed\path/separators" }, - NormalizedOnWindows = @"C:\some\mixed\path\separators", - NormalizedOnUnix = @"C:/some/mixed/path/separators" - }, + NormalizedOnWindows: @"C:\some\mixed\path\separators", + NormalizedOnUnix: @"C:/some/mixed/path/separators" + ) }; @@ -281,14 +272,14 @@ namespace SMAPI.Tests.Utilities /********* ** Private classes *********/ - public class SamplePath + /// <summary>A sample path in multiple formats.</summary> + /// <param name="OriginalPath">The original path to pass to the <see cref="PathUtilities"/>.</param> + /// <param name="Segments">The normalized path segments.</param> + /// <param name="SegmentsLimit3">The normalized path segments, if we stop segmenting after the second one.</param> + /// <param name="NormalizedOnWindows">The normalized form on Windows.</param> + /// <param name="NormalizedOnUnix">The normalized form on Linux or macOS.</param> + public record SamplePath(string OriginalPath, string[] Segments, string[] SegmentsLimit3, string NormalizedOnWindows, string NormalizedOnUnix) { - public string OriginalPath { get; set; } - public string[] Segments { get; set; } - public string[] SegmentsLimit3 { get; set; } - public string NormalizedOnWindows { get; set; } - public string NormalizedOnUnix { get; set; } - public override string ToString() { return this.OriginalPath; diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index 0461952e..b9c3d202 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -16,9 +16,12 @@ namespace SMAPI.Tests.Utilities /********* ** Fields *********/ - /// <summary>All valid seasons.</summary> + /// <summary>The valid seasons.</summary> private static readonly string[] ValidSeasons = { "spring", "summer", "fall", "winter" }; + /// <summary>Sample user inputs for season names.</summary> + private static readonly string[] SampleSeasonValues = SDateTests.ValidSeasons.Concat(new[] { " WIntEr " }).ToArray(); + /// <summary>All valid days of a month.</summary> private static readonly int[] ValidDays = Enumerable.Range(1, 28).ToArray(); @@ -55,19 +58,18 @@ namespace SMAPI.Tests.Utilities ** Constructor ****/ [Test(Description = "Assert that the constructor sets the expected values for all valid dates.")] - public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.ValidSeasons))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) + public void Constructor_SetsExpectedValues([ValueSource(nameof(SDateTests.SampleSeasonValues))] string season, [ValueSource(nameof(SDateTests.ValidDays))] int day, [Values(1, 2, 100)] int year) { // act - SDate date = new SDate(day, season, year); + SDate date = new(day, season, year); // assert Assert.AreEqual(day, date.Day); - Assert.AreEqual(season, date.Season); + Assert.AreEqual(season.Trim().ToLowerInvariant(), date.Season); Assert.AreEqual(year, date.Year); } [Test(Description = "Assert that the constructor throws an exception if the values are invalid.")] - [TestCase(01, "Spring", 1)] // seasons are case-sensitive [TestCase(01, "springs", 1)] // invalid season name [TestCase(-1, "spring", 1)] // day < 0 [TestCase(0, "spring", 1)] // day zero @@ -252,9 +254,9 @@ namespace SMAPI.Tests.Utilities { foreach (int day in SDateTests.ValidDays) { - SDate date = new SDate(day, season, year); + SDate date = new(day, season, year); int hash = date.GetHashCode(); - if (hashes.TryGetValue(hash, out SDate otherDate)) + if (hashes.TryGetValue(hash, out SDate? otherDate)) Assert.Fail($"Received identical hash code {hash} for dates {otherDate} and {date}."); if (hash < lastHash) Assert.Fail($"Received smaller hash code for date {date} ({hash}) relative to {hashes[lastHash]} ({lastHash})."); @@ -294,7 +296,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_Equals(string now, string other) + public bool Operators_Equals(string? now, string other) { return this.GetDate(now) == this.GetDate(other); } @@ -308,7 +310,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_NotEquals(string now, string other) + public bool Operators_NotEquals(string? now, string other) { return this.GetDate(now) != this.GetDate(other); } @@ -322,7 +324,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThan(string now, string other) + public bool Operators_LessThan(string? now, string other) { return this.GetDate(now) < this.GetDate(other); } @@ -336,7 +338,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = true)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = true)] - public bool Operators_LessThanOrEqual(string now, string other) + public bool Operators_LessThanOrEqual(string? now, string other) { return this.GetDate(now) <= this.GetDate(other); } @@ -350,7 +352,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThan(string now, string other) + public bool Operators_MoreThan(string? now, string other) { return this.GetDate(now) > this.GetDate(other); } @@ -364,7 +366,7 @@ namespace SMAPI.Tests.Utilities [TestCase(Dates.Now, Dates.NextDay, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextMonth, ExpectedResult = false)] [TestCase(Dates.Now, Dates.NextYear, ExpectedResult = false)] - public bool Operators_MoreThanOrEqual(string now, string other) + public bool Operators_MoreThanOrEqual(string? now, string other) { return this.GetDate(now) > this.GetDate(other); } @@ -375,7 +377,8 @@ namespace SMAPI.Tests.Utilities *********/ /// <summary>Convert a string date into a game date, to make unit tests easier to read.</summary> /// <param name="dateStr">The date string like "dd MMMM yy".</param> - private SDate GetDate(string dateStr) + [return: NotNullIfNotNull("dateStr")] + private SDate? GetDate(string? dateStr) { if (dateStr == null) return null; diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index ac4ef39b..fedadba6 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -61,10 +61,10 @@ namespace SMAPI.Tests.Utilities [TestCase("apple")] [TestCase("-apple")] [TestCase("-5")] - public void Constructor_FromString_WithInvalidValues(string input) + public void Constructor_FromString_WithInvalidValues(string? input) { if (input == null) - this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input)); + this.AssertAndLogException<ArgumentNullException>(() => new SemanticVersion(input!)); else this.AssertAndLogException<FormatException>(() => new SemanticVersion(input)); } @@ -91,7 +91,7 @@ namespace SMAPI.Tests.Utilities [TestCase("1.2.3.4-some-tag.4 ")] public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input) { - Assert.Throws<FormatException>(() => new SemanticVersion(input)); + Assert.Throws<FormatException>(() => _ = new SemanticVersion(input)); } /// <summary>Assert the parsed version when constructed from standard parts.</summary> @@ -110,7 +110,7 @@ namespace SMAPI.Tests.Utilities [TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")] [TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")] [TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")] - public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build) + public string Constructor_FromParts(int major, int minor, int patch, string? prerelease, string? build) { // act ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build); @@ -217,11 +217,16 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = 1)] [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = 1)] [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)] - public int CompareTo(string versionStrA, string versionStrB) + + // null + [TestCase("1.0.0", null, ExpectedResult = 1)] // null is always less than any value per CompareTo remarks + public int CompareTo(string versionStrA, string? versionStrB) { // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion versionB = new SemanticVersion(versionStrB); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; // assert return versionA.CompareTo(versionB); @@ -260,14 +265,19 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = false)] [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = false)] [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)] - public bool IsOlderThan(string versionStrA, string versionStrB) + + // null + [TestCase("1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks + public bool IsOlderThan(string versionStrA, string? versionStrB) { // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion versionB = new SemanticVersion(versionStrB); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; // assert - Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB.ToString()), "The two signatures returned different results."); + Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB?.ToString()), "The two signatures returned different results."); return versionA.IsOlderThan(versionB); } @@ -304,14 +314,19 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta.2", "1.0-beta.1", ExpectedResult = true)] [TestCase("1.0-beta.10", "1.0-beta.2", ExpectedResult = true)] [TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)] - public bool IsNewerThan(string versionStrA, string versionStrB) + + // null + [TestCase("1.0.0", null, ExpectedResult = true)] // null is always less than any value per CompareTo remarks + public bool IsNewerThan(string versionStrA, string? versionStrB) { // arrange ISemanticVersion versionA = new SemanticVersion(versionStrA); - ISemanticVersion versionB = new SemanticVersion(versionStrB); + ISemanticVersion? versionB = versionStrB != null + ? new SemanticVersion(versionStrB) + : null; // assert - Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB.ToString()), "The two signatures returned different results."); + Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB?.ToString()), "The two signatures returned different results."); return versionA.IsNewerThan(versionB); } @@ -322,7 +337,7 @@ namespace SMAPI.Tests.Utilities /// <param name="versionStr">The main version.</param> /// <param name="lowerStr">The lower version number.</param> /// <param name="upperStr">The upper version number.</param> - [Test(Description = "Assert that version.IsNewerThan returns the expected value.")] + [Test(Description = "Assert that version.IsBetween returns the expected value.")] // is between [TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)] [TestCase("1.0", "1.0", "1.1", ExpectedResult = true)] @@ -330,6 +345,7 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0", "0.5", "1.1", ExpectedResult = true)] [TestCase("1.0-beta.2", "1.0-beta.1", "1.0-beta.3", ExpectedResult = true)] [TestCase("1.0-beta-2", "1.0-beta-1", "1.0-beta-3", ExpectedResult = true)] + [TestCase("1.0.0", null, "1.0.0", ExpectedResult = true)] // null is always less than any value per CompareTo remarks // is not between [TestCase("1.0-beta", "1.0", "1.1", ExpectedResult = false)] @@ -337,15 +353,20 @@ namespace SMAPI.Tests.Utilities [TestCase("1.0-beta.2", "1.1", "1.0", ExpectedResult = false)] [TestCase("1.0-beta.2", "1.0-beta.10", "1.0-beta.3", ExpectedResult = false)] [TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)] - public bool IsBetween(string versionStr, string lowerStr, string upperStr) + [TestCase("1.0.0", "1.0.0", null, ExpectedResult = false)] // null is always less than any value per CompareTo remarks + public bool IsBetween(string versionStr, string? lowerStr, string? upperStr) { // arrange - ISemanticVersion lower = new SemanticVersion(lowerStr); - ISemanticVersion upper = new SemanticVersion(upperStr); + ISemanticVersion? lower = lowerStr != null + ? new SemanticVersion(lowerStr) + : null; + ISemanticVersion? upper = upperStr != null + ? new SemanticVersion(upperStr) + : null; ISemanticVersion version = new SemanticVersion(versionStr); // assert - Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower.ToString(), upper.ToString()), "The two signatures returned different results."); + Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower?.ToString(), upper?.ToString()), "The two signatures returned different results."); return version.IsBetween(lower, upper); } @@ -395,7 +416,7 @@ namespace SMAPI.Tests.Utilities public void GameVersion(string versionStr) { // act - GameVersion version = new GameVersion(versionStr); + GameVersion version = new(versionStr); // assert Assert.AreEqual(versionStr, version.ToString(), "The game version did not round-trip to the same value."); @@ -413,7 +434,7 @@ namespace SMAPI.Tests.Utilities /// <param name="prerelease">The prerelease tag.</param> /// <param name="build">The build metadata.</param> /// <param name="nonStandard">Whether the version should be marked as non-standard.</param> - private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string prerelease, string build, bool nonStandard) + private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string? prerelease, string? build, bool nonStandard) { Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match."); Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match."); @@ -426,9 +447,8 @@ namespace SMAPI.Tests.Utilities /// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary> /// <typeparam name="T">The expected exception type.</typeparam> /// <param name="action">The action which may throw the exception.</param> - /// <param name="message">The message to log if the expected exception isn't thrown.</param> [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] - private void AssertAndLogException<T>(Func<object> action, string message = null) + private void AssertAndLogException<T>(Func<object> action) where T : Exception { this.AssertAndLogException<T>(() => @@ -443,7 +463,7 @@ namespace SMAPI.Tests.Utilities /// <param name="action">The action which may throw the exception.</param> /// <param name="message">The message to log if the expected exception isn't thrown.</param> [SuppressMessage("ReSharper", "UnusedParameter.Local", Justification = "The message argument is deliberately only used in precondition checks since this is an assertion method.")] - private void AssertAndLogException<T>(Action action, string message = null) + private void AssertAndLogException<T>(Action action, string? message = null) where T : Exception { try @@ -455,7 +475,7 @@ namespace SMAPI.Tests.Utilities TestContext.WriteLine($"Exception thrown:\n{ex}"); return; } - catch (Exception ex) when (!(ex is AssertionException)) + catch (Exception ex) when (ex is not AssertionException) { TestContext.WriteLine($"Exception thrown:\n{ex}"); Assert.Fail(message ?? $"Didn't throw the expected exception; expected {typeof(T).FullName}, got {ex.GetType().FullName}."); diff --git a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs index b896b09c..8e7e1fb8 100644 --- a/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs +++ b/src/SMAPI.Tests/WikiClient/ChangeDescriptorTests.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using NUnit.Framework; using StardewModdingAPI; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -21,15 +20,14 @@ namespace SMAPI.Tests.WikiClient { // arrange string rawDescriptor = "-Nexus:2400, -B, XX → YY, Nexus:451,+A, XXX → YYY, invalidA →, → invalidB"; - string[] expectedAdd = new[] { "Nexus:451", "A" }; - string[] expectedRemove = new[] { "Nexus:2400", "B" }; + string[] expectedAdd = { "Nexus:451", "A" }; + string[] expectedRemove = { "Nexus:2400", "B" }; IDictionary<string, string> expectedReplace = new Dictionary<string, string> { ["XX"] = "YY", ["XXX"] = "YYY" }; - string[] expectedErrors = new[] - { + string[] expectedErrors = { "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." }; @@ -49,15 +47,14 @@ namespace SMAPI.Tests.WikiClient { // arrange string rawDescriptor = "-1.0.1, -2.0-beta, 1.00 → 1.0, 1.0.0,+2.0-beta.15, 2.0 → 2.0-beta, invalidA →, → invalidB"; - string[] expectedAdd = new[] { "1.0.0", "2.0.0-beta.15" }; - string[] expectedRemove = new[] { "1.0.1", "2.0.0-beta" }; + string[] expectedAdd = { "1.0.0", "2.0.0-beta.15" }; + string[] expectedRemove = { "1.0.1", "2.0.0-beta" }; IDictionary<string, string> expectedReplace = new Dictionary<string, string> { ["1.00"] = "1.0.0", ["2.0.0"] = "2.0.0-beta" }; - string[] expectedErrors = new[] - { + string[] expectedErrors = { "Failed parsing ' invalidA →': can't map to a blank value. Use the '-value' format to remove a value.", "Failed parsing ' → invalidB': can't map from a blank old value. Use the '+value' format to add a value." }; @@ -66,7 +63,7 @@ namespace SMAPI.Tests.WikiClient ChangeDescriptor parsed = ChangeDescriptor.Parse( rawDescriptor, out string[] errors, - formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) + formatValue: raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw ); @@ -110,9 +107,9 @@ namespace SMAPI.Tests.WikiClient [TestCase("", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:A, Nexus:B")] [TestCase("Nexus:2400", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:A, Nexus:B")] [TestCase("Nexus:2400, Nexus:2401, Nexus:B,Chucklefish:14", "+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "Nexus:2401, Nexus:2401, Nexus:B, Nexus:A")] - public string Apply_Raw(string input, string descriptor) + public string Apply_Raw(string input, string? descriptor) { - var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); + ChangeDescriptor parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); Assert.IsEmpty(errors, "Parsing the descriptor failed."); @@ -127,7 +124,7 @@ namespace SMAPI.Tests.WikiClient [TestCase("-Nexus:2400", ExpectedResult = "-Nexus:2400")] [TestCase(" Nexus:2400 →Nexus:2401 ", ExpectedResult = "Nexus:2400 → Nexus:2401")] [TestCase("+Nexus:A, Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A→Nexus:B", ExpectedResult = "+Nexus:A, +Nexus:B, -Chucklefish:14, Nexus:2400 → Nexus:2401, Nexus:A → Nexus:B")] - public string ToString(string descriptor) + public string ToString(string? descriptor) { var parsed = ChangeDescriptor.Parse(descriptor, out string[] errors); diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs index 7375f005..ee6cc0b6 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifest.cs @@ -21,16 +21,16 @@ namespace StardewModdingAPI ISemanticVersion Version { get; } /// <summary>The minimum SMAPI version required by this mod, if any.</summary> - ISemanticVersion MinimumApiVersion { get; } + ISemanticVersion? MinimumApiVersion { get; } /// <summary>The unique mod ID.</summary> string UniqueID { get; } /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary> - string EntryDll { get; } + string? EntryDll { get; } /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="EntryDll"/>.</summary> - IManifestContentPackFor ContentPackFor { get; } + IManifestContentPackFor? ContentPackFor { get; } /// <summary>The other mods that must be loaded before this mod.</summary> IManifestDependency[] Dependencies { get; } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs index f05a3873..52ac8f1c 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifestContentPackFor.cs @@ -7,6 +7,6 @@ namespace StardewModdingAPI string UniqueID { get; } /// <summary>The minimum required version (if any).</summary> - ISemanticVersion MinimumVersion { get; } + ISemanticVersion? MinimumVersion { get; } } } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs b/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs index e86cd1f4..58425eb2 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/IManifestDependency.cs @@ -10,7 +10,7 @@ namespace StardewModdingAPI string UniqueID { get; } /// <summary>The minimum required version (if any).</summary> - ISemanticVersion MinimumVersion { get; } + ISemanticVersion? MinimumVersion { get; } /// <summary>Whether the dependency must be installed to use the mod.</summary> bool IsRequired { get; } diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index b228b2d1..dc226b7c 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI { @@ -18,46 +19,55 @@ namespace StardewModdingAPI int PatchVersion { get; } /// <summary>An optional prerelease tag.</summary> - string PrereleaseTag { get; } + string? PrereleaseTag { get; } /// <summary>Optional build metadata. This is ignored when determining version precedence.</summary> - string BuildMetadata { get; } + string? BuildMetadata { get; } /********* ** Accessors *********/ /// <summary>Whether this is a prerelease version.</summary> +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(ISemanticVersion.PrereleaseTag))] +#endif bool IsPrerelease(); /// <summary>Get whether this version is older than the specified version.</summary> /// <param name="other">The version to compare with this instance.</param> - bool IsOlderThan(ISemanticVersion other); + /// <remarks>Although the <paramref name="other"/> parameter is nullable, it isn't optional. A <c>null</c> version is considered earlier than every possible valid version, so passing <c>null</c> to <paramref name="other"/> will always return false.</remarks> + bool IsOlderThan(ISemanticVersion? other); /// <summary>Get whether this version is older than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <param name="other">The version to compare with this instance. A null value is never older.</param> /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> - bool IsOlderThan(string other); + /// <remarks>Although the <paramref name="other"/> parameter is nullable, it isn't optional. A <c>null</c> version is considered earlier than every possible valid version, so passing <c>null</c> to <paramref name="other"/> will always return false.</remarks> + bool IsOlderThan(string? other); /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> - bool IsNewerThan(ISemanticVersion other); + /// <param name="other">The version to compare with this instance. A null value is always older.</param> + /// <remarks>Although the <paramref name="other"/> parameter is nullable, it isn't optional. A <c>null</c> version is considered earlier than every possible valid version, so passing <c>null</c> to <paramref name="other"/> will always return true.</remarks> + bool IsNewerThan(ISemanticVersion? other); /// <summary>Get whether this version is newer than the specified version.</summary> - /// <param name="other">The version to compare with this instance.</param> + /// <param name="other">The version to compare with this instance. A null value is always older.</param> /// <exception cref="FormatException">The specified version is not a valid semantic version.</exception> - bool IsNewerThan(string other); + /// <remarks>Although the <paramref name="other"/> parameter is nullable, it isn't optional. A <c>null</c> version is considered earlier than every possible valid version, so passing <c>null</c> to <paramref name="other"/> will always return true.</remarks> + bool IsNewerThan(string? other); /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> - bool IsBetween(ISemanticVersion min, ISemanticVersion max); + /// <param name="min">The minimum version. A null value is always older.</param> + /// <param name="max">The maximum version. A null value is never newer.</param> + /// <remarks>Although the <paramref name="min"/> and <paramref name="max"/> parameters are nullable, they are not optional. A <c>null</c> version is considered earlier than every possible valid version. For example, passing <c>null</c> to <paramref name="max"/> will always return false, since no valid version can be earlier than <c>null</c>.</remarks> + bool IsBetween(ISemanticVersion? min, ISemanticVersion? max); /// <summary>Get whether this version is between two specified versions (inclusively).</summary> - /// <param name="min">The minimum version.</param> - /// <param name="max">The maximum version.</param> + /// <param name="min">The minimum version. A null value is always older.</param> + /// <param name="max">The maximum version. A null value is never newer.</param> /// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception> - bool IsBetween(string min, string max); + /// <remarks>Although the <paramref name="min"/> and <paramref name="max"/> parameters are nullable, they are not optional. A <c>null</c> version is considered earlier than every possible valid version. For example, passing <c>null</c> to <paramref name="max"/> will always return false, since no valid version can be earlier than <c>null</c>.</remarks> + bool IsBetween(string? min, string? max); /// <summary>Get a string representation of the version.</summary> string ToString(); diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 2f58a3f1..4fc4ea54 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// <summary>Metadata about a mod.</summary> @@ -7,15 +9,26 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Accessors *********/ /// <summary>The mod's unique ID (if known).</summary> - public string ID { get; set; } + public string ID { get; } /// <summary>The update version recommended by the web API based on its version update and mapping rules.</summary> - public ModEntryVersionModel SuggestedUpdate { get; set; } + public ModEntryVersionModel? SuggestedUpdate { get; set; } /// <summary>Optional extended data which isn't needed for update checks.</summary> - public ModExtendedMetadataModel Metadata { get; set; } + public ModExtendedMetadataModel? Metadata { get; set; } /// <summary>The errors that occurred while fetching update data.</summary> - public string[] Errors { get; set; } = new string[0]; + public string[] Errors { get; set; } = Array.Empty<string>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID (if known).</param> + public ModEntryModel(string id) + { + this.ID = id; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs index 188db31d..a1e78986 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryVersionModel.cs @@ -11,19 +11,16 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi *********/ /// <summary>The version number.</summary> [JsonConverter(typeof(NonStandardSemanticVersionConverter))] - public ISemanticVersion Version { get; set; } + public ISemanticVersion Version { get; } /// <summary>The mod page URL.</summary> - public string Url { get; set; } + public string Url { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public ModEntryVersionModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="version">The version number.</param> /// <param name="url">The mod page URL.</param> public ModEntryVersionModel(ISemanticVersion version, string url) diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 5c2ce366..272a2063 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; @@ -17,10 +18,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Mod info ****/ /// <summary>The mod's unique ID. A mod may have multiple current IDs in rare cases (e.g. due to parallel releases or unofficial updates).</summary> - public string[] ID { get; set; } = new string[0]; + public string[] ID { get; set; } = Array.Empty<string>(); /// <summary>The mod's display name.</summary> - public string Name { get; set; } + public string? Name { get; set; } /// <summary>The mod ID on Nexus.</summary> public int? NexusID { get; set; } @@ -32,31 +33,31 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public int? CurseForgeID { get; set; } /// <summary>The mod key in the CurseForge mod repo (used in mod page URLs).</summary> - public string CurseForgeKey { get; set; } + public string? CurseForgeKey { get; set; } /// <summary>The mod ID in the ModDrop mod repo.</summary> public int? ModDropID { get; set; } /// <summary>The GitHub repository in the form 'owner/repo'.</summary> - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; set; } /// <summary>The URL to a non-GitHub source repo.</summary> - public string CustomSourceUrl { get; set; } + public string? CustomSourceUrl { get; set; } /// <summary>The custom mod page URL (if applicable).</summary> - public string CustomUrl { get; set; } + public string? CustomUrl { get; set; } /// <summary>The main version.</summary> - public ModEntryVersionModel Main { get; set; } + public ModEntryVersionModel? Main { get; set; } /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary> - public ModEntryVersionModel Optional { get; set; } + public ModEntryVersionModel? Optional { get; set; } /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> - public ModEntryVersionModel Unofficial { get; set; } + public ModEntryVersionModel? Unofficial { get; set; } /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any.</summary> - public ModEntryVersionModel UnofficialForBeta { get; set; } + public ModEntryVersionModel? UnofficialForBeta { get; set; } /**** ** Stable compatibility @@ -66,10 +67,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public WikiCompatibilityStatus? CompatibilityStatus { get; set; } /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> - public string CompatibilitySummary { get; set; } + public string? CompatibilitySummary { get; set; } /// <summary>The game or SMAPI version which broke this mod, if applicable.</summary> - public string BrokeIn { get; set; } + public string? BrokeIn { get; set; } /**** ** Beta compatibility @@ -79,22 +80,22 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } /// <summary>The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatting.</summary> - public string BetaCompatibilitySummary { get; set; } + public string? BetaCompatibilitySummary { get; set; } /// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary> - public string BetaBrokeIn { get; set; } + public string? BetaBrokeIn { get; set; } /**** ** Version mappings ****/ /// <summary>A serialized change descriptor to apply to the local version during update checks (see <see cref="ChangeDescriptor"/>).</summary> - public string ChangeLocalVersions { get; set; } + public string? ChangeLocalVersions { get; set; } /// <summary>A serialized change descriptor to apply to the remote version during update checks (see <see cref="ChangeDescriptor"/>).</summary> - public string ChangeRemoteVersions { get; set; } + public string? ChangeRemoteVersions { get; set; } /// <summary>A serialized change descriptor to apply to the update keys during update checks (see <see cref="ChangeDescriptor"/>).</summary> - public string ChangeUpdateKeys { get; set; } + public string? ChangeUpdateKeys { get; set; } /********* @@ -110,7 +111,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <param name="optional">The latest optional version, if newer than <paramref name="main"/>.</param> /// <param name="unofficial">The latest unofficial version, if newer than <paramref name="main"/> and <paramref name="optional"/>.</param> /// <param name="unofficialForBeta">The latest unofficial version for the current Stardew Valley or SMAPI beta, if any.</param> - public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db, ModEntryVersionModel main, ModEntryVersionModel optional, ModEntryVersionModel unofficial, ModEntryVersionModel unofficialForBeta) + public ModExtendedMetadataModel(WikiModEntry? wiki, ModDataRecord? db, ModEntryVersionModel? main, ModEntryVersionModel? optional, ModEntryVersionModel? unofficial, ModEntryVersionModel? unofficialForBeta) { // versions this.Main = main; diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs index bf81e102..9c11e1db 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -1,3 +1,6 @@ +using System; +using System.Linq; + namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { /// <summary>Specifies the identifiers for a mod to match.</summary> @@ -7,37 +10,39 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi ** Accessors *********/ /// <summary>The unique mod ID.</summary> - public string ID { get; set; } + public string ID { get; } /// <summary>The namespaced mod update keys (if available).</summary> - public string[] UpdateKeys { get; set; } + public string[] UpdateKeys { get; private set; } /// <summary>The mod version installed by the local player. This is used for version mapping in some cases.</summary> - public ISemanticVersion InstalledVersion { get; set; } + public ISemanticVersion? InstalledVersion { get; } /// <summary>Whether the installed version is broken or could not be loaded.</summary> - public bool IsBroken { get; set; } + public bool IsBroken { get; } /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public ModSearchEntryModel() - { - // needed for JSON deserializing - } - /// <summary>Construct an instance.</summary> /// <param name="id">The unique mod ID.</param> /// <param name="installedVersion">The version installed by the local player. This is used for version mapping in some cases.</param> /// <param name="updateKeys">The namespaced mod update keys (if available).</param> /// <param name="isBroken">Whether the installed version is broken or could not be loaded.</param> - public ModSearchEntryModel(string id, ISemanticVersion installedVersion, string[] updateKeys, bool isBroken = false) + public ModSearchEntryModel(string id, ISemanticVersion? installedVersion, string[]? updateKeys, bool isBroken = false) { this.ID = id; this.InstalledVersion = installedVersion; - this.UpdateKeys = updateKeys ?? new string[0]; + this.UpdateKeys = updateKeys ?? Array.Empty<string>(); + this.IsBroken = isBroken; + } + + /// <summary>Add update keys for the mod.</summary> + /// <param name="updateKeys">The update keys to add.</param> + public void AddUpdateKeys(params string[] updateKeys) + { + this.UpdateKeys = this.UpdateKeys.Concat(updateKeys).ToArray(); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs index 73698173..3c74bab0 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs @@ -1,3 +1,5 @@ +using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Utilities; @@ -22,16 +24,23 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi public ISemanticVersion GameVersion { get; set; } /// <summary>The OS on which the player plays.</summary> - public Platform? Platform { get; set; } + public Platform Platform { get; set; } /********* ** Public methods *********/ /// <summary>Construct an empty instance.</summary> + [Obsolete("This constructor only exists to support ASP.NET model binding, and shouldn't be used directly.")] + [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "Used by ASP.NET model binding.")] public ModSearchModel() { - // needed for JSON deserializing + // ASP.NET Web API needs a public empty constructor for top-level request models, and + // it'll fail if the other constructor is marked with [JsonConstructor]. Apparently + // it's fine with non-empty constructors in nested models like ModSearchEntryModel. + this.Mods = Array.Empty<ModSearchEntryModel>(); + this.ApiVersion = null!; + this.GameVersion = null!; } /// <summary>Construct an instance.</summary> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index c2d906a0..d4282617 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -62,15 +62,16 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi private TResult Post<TBody, TResult>(string url, TBody content) { // note: avoid HttpClient for macOS compatibility - using WebClient client = new WebClient(); + using WebClient client = new(); - Uri fullUrl = new Uri(this.BaseUrl, url); + Uri fullUrl = new(this.BaseUrl, url); string data = JsonConvert.SerializeObject(content); client.Headers["Content-Type"] = "application/json"; client.Headers["User-Agent"] = $"SMAPI/{this.Version}"; string response = client.UploadString(fullUrl, data); - return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings); + return JsonConvert.DeserializeObject<TResult>(response, this.JsonSettings) + ?? throw new InvalidOperationException($"Could not parse the response from POST {url}."); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs index f1feb44b..a2497dea 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/ChangeDescriptor.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Linq; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki @@ -47,11 +48,19 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Apply the change descriptors to a comma-delimited field.</summary> /// <param name="rawField">The raw field text.</param> /// <returns>Returns the modified field.</returns> - public string ApplyToCopy(string rawField) +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("rawField")] +#endif + public string? ApplyToCopy(string? rawField) { // get list List<string> values = !string.IsNullOrWhiteSpace(rawField) - ? new List<string>(rawField.Split(',')) + ? new List<string>( + from field in rawField.Split(',') + let value = field.Trim() + where value.Length > 0 + select value + ) : new List<string>(); // apply changes @@ -73,12 +82,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { for (int i = values.Count - 1; i >= 0; i--) { - string value = this.FormatValue(values[i]?.Trim() ?? string.Empty); + string value = this.FormatValue(values[i].Trim()); if (this.Remove.Contains(value)) values.RemoveAt(i); - else if (this.Replace.TryGetValue(value, out string newValue)) + else if (this.Replace.TryGetValue(value, out string? newValue)) values[i] = newValue; } } @@ -86,7 +95,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki // add values if (this.Add.Any()) { - HashSet<string> curValues = new HashSet<string>(values.Select(p => p?.Trim() ?? string.Empty), StringComparer.OrdinalIgnoreCase); + HashSet<string> curValues = new HashSet<string>(values.Select(p => p.Trim()), StringComparer.OrdinalIgnoreCase); foreach (string add in this.Add) { if (!curValues.Contains(add)) @@ -119,7 +128,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <param name="descriptor">The raw change descriptor.</param> /// <param name="errors">The human-readable error message describing any invalid values that were ignored.</param> /// <param name="formatValue">Format a raw value into a normalized form if needed.</param> - public static ChangeDescriptor Parse(string descriptor, out string[] errors, Func<string, string> formatValue = null) + public static ChangeDescriptor Parse(string? descriptor, out string[] errors, Func<string, string>? formatValue = null) { // init formatValue ??= p => p; @@ -179,7 +188,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki errors = rawErrors.ToArray(); } else - errors = new string[0]; + errors = Array.Empty<string>(); // build model return new ChangeDescriptor( diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index f85e82e1..7f06d170 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -51,8 +51,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki doc.LoadHtml(html); // fetch game versions - string stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; - string betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; + string? stableVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-stable-version']")?.InnerText; + string? betaVersion = doc.DocumentNode.SelectSingleNode("//div[@class='game-beta-version']")?.InnerText; if (betaVersion == stableVersion) betaVersion = null; @@ -63,9 +63,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki if (modNodes == null) throw new InvalidOperationException("Can't parse wiki compatibility list, no mod data overrides section found."); - foreach (var entry in this.ParseOverrideEntries(modNodes)) + foreach (WikiDataOverrideEntry entry in this.ParseOverrideEntries(modNodes)) { - if (entry.Ids?.Any() != true || !entry.HasChanges) + if (entry.Ids.Any() != true || !entry.HasChanges) continue; foreach (string id in entry.Ids) @@ -83,18 +83,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } // build model - return new WikiModList - { - StableVersion = stableVersion, - BetaVersion = betaVersion, - Mods = mods - }; + return new WikiModList( + stableVersion: stableVersion, + betaVersion: betaVersion, + mods: mods + ); } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -116,71 +115,68 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki int? nexusID = this.GetAttributeAsNullableInt(node, "data-nexus-id"); int? chucklefishID = this.GetAttributeAsNullableInt(node, "data-cf-id"); int? curseForgeID = this.GetAttributeAsNullableInt(node, "data-curseforge-id"); - string curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); + string? curseForgeKey = this.GetAttribute(node, "data-curseforge-key"); int? modDropID = this.GetAttributeAsNullableInt(node, "data-moddrop-id"); - string githubRepo = this.GetAttribute(node, "data-github"); - string customSourceUrl = this.GetAttribute(node, "data-custom-source"); - string customUrl = this.GetAttribute(node, "data-url"); - string anchor = this.GetAttribute(node, "id"); - string contentPackFor = this.GetAttribute(node, "data-content-pack-for"); - string devNote = this.GetAttribute(node, "data-dev-note"); - string pullRequestUrl = this.GetAttribute(node, "data-pr"); + string? githubRepo = this.GetAttribute(node, "data-github"); + string? customSourceUrl = this.GetAttribute(node, "data-custom-source"); + string? customUrl = this.GetAttribute(node, "data-url"); + string? anchor = this.GetAttribute(node, "id"); + string? contentPackFor = this.GetAttribute(node, "data-content-pack-for"); + string? devNote = this.GetAttribute(node, "data-dev-note"); + string? pullRequestUrl = this.GetAttribute(node, "data-pr"); // parse stable compatibility - WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo - { - Status = this.GetAttributeAsEnum<WikiCompatibilityStatus>(node, "data-status") ?? WikiCompatibilityStatus.Ok, - BrokeIn = this.GetAttribute(node, "data-broke-in"), - UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), - UnofficialUrl = this.GetAttribute(node, "data-unofficial-url"), - Summary = this.GetInnerHtml(node, "mod-summary")?.Trim() - }; + WikiCompatibilityInfo compatibility = new( + status: this.GetAttributeAsEnum<WikiCompatibilityStatus>(node, "data-status") ?? WikiCompatibilityStatus.Ok, + brokeIn: this.GetAttribute(node, "data-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-summary")?.Trim() + ); // parse beta compatibility - WikiCompatibilityInfo betaCompatibility = null; + WikiCompatibilityInfo? betaCompatibility = null; { WikiCompatibilityStatus? betaStatus = this.GetAttributeAsEnum<WikiCompatibilityStatus>(node, "data-beta-status"); if (betaStatus.HasValue) { - betaCompatibility = new WikiCompatibilityInfo - { - Status = betaStatus.Value, - BrokeIn = this.GetAttribute(node, "data-beta-broke-in"), - UnofficialVersion = this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), - UnofficialUrl = this.GetAttribute(node, "data-beta-unofficial-url"), - Summary = this.GetInnerHtml(node, "mod-beta-summary") - }; + betaCompatibility = new WikiCompatibilityInfo( + status: betaStatus.Value, + brokeIn: this.GetAttribute(node, "data-beta-broke-in"), + unofficialVersion: this.GetAttributeAsSemanticVersion(node, "data-beta-unofficial-version"), + unofficialUrl: this.GetAttribute(node, "data-beta-unofficial-url"), + summary: this.GetInnerHtml(node, "mod-beta-summary") + ); } } // find data overrides - WikiDataOverrideEntry overrides = ids + WikiDataOverrideEntry? overrides = ids .Select(id => overridesById.TryGetValue(id, out overrides) ? overrides : null) .FirstOrDefault(p => p != null); // yield model - yield return new WikiModEntry - { - ID = ids, - Name = names, - Author = authors, - NexusID = nexusID, - ChucklefishID = chucklefishID, - CurseForgeID = curseForgeID, - CurseForgeKey = curseForgeKey, - ModDropID = modDropID, - GitHubRepo = githubRepo, - CustomSourceUrl = customSourceUrl, - CustomUrl = customUrl, - ContentPackFor = contentPackFor, - Compatibility = compatibility, - BetaCompatibility = betaCompatibility, - Warnings = warnings, - PullRequestUrl = pullRequestUrl, - DevNote = devNote, - Overrides = overrides, - Anchor = anchor - }; + yield return new WikiModEntry( + id: ids, + name: names, + author: authors, + nexusId: nexusID, + chucklefishId: chucklefishID, + curseForgeId: curseForgeID, + curseForgeKey: curseForgeKey, + modDropId: modDropID, + githubRepo: githubRepo, + customSourceUrl: customSourceUrl, + customUrl: customUrl, + contentPackFor: contentPackFor, + compatibility: compatibility, + betaCompatibility: betaCompatibility, + warnings: warnings, + pullRequestUrl: pullRequestUrl, + devNote: devNote, + overrides: overrides, + anchor: anchor + ); } } @@ -194,10 +190,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { Ids = this.GetAttributeAsCsv(node, "data-id"), ChangeLocalVersions = this.GetAttributeAsChangeDescriptor(node, "data-local-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw ), ChangeRemoteVersions = this.GetAttributeAsChangeDescriptor(node, "data-remote-version", - raw => SemanticVersion.TryParse(raw, out ISemanticVersion version) ? version.ToString() : raw + raw => SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version.ToString() : raw ), ChangeUpdateKeys = this.GetAttributeAsChangeDescriptor(node, "data-update-keys", @@ -210,7 +206,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Get an attribute value.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> - private string GetAttribute(HtmlNode element, string name) + private string? GetAttribute(HtmlNode element, string name) { string value = element.GetAttributeValue(name, null); if (string.IsNullOrWhiteSpace(value)) @@ -223,9 +219,9 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> /// <param name="formatValue">Format an raw entry value when applying changes.</param> - private ChangeDescriptor GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func<string, string> formatValue) + private ChangeDescriptor? GetAttributeAsChangeDescriptor(HtmlNode element, string name, Func<string, string> formatValue) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); return raw != null ? ChangeDescriptor.Parse(raw, out _, formatValue) : null; @@ -236,10 +232,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <param name="name">The attribute name.</param> private string[] GetAttributeAsCsv(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); return !string.IsNullOrWhiteSpace(raw) ? raw.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(p => p.Trim()).ToArray() - : new string[0]; + : Array.Empty<string>(); } /// <summary>Get an attribute value and parse it as an enum value.</summary> @@ -248,7 +244,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <param name="name">The attribute name.</param> private TEnum? GetAttributeAsEnum<TEnum>(HtmlNode element, string name) where TEnum : struct { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); if (raw == null) return null; if (!Enum.TryParse(raw, true, out TEnum value) && Enum.IsDefined(typeof(TEnum), value)) @@ -259,10 +255,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Get an attribute value and parse it as a semantic version.</summary> /// <param name="element">The element whose attributes to read.</param> /// <param name="name">The attribute name.</param> - private ISemanticVersion GetAttributeAsSemanticVersion(HtmlNode element, string name) + private ISemanticVersion? GetAttributeAsSemanticVersion(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); - return SemanticVersion.TryParse(raw, out ISemanticVersion version) + string? raw = this.GetAttribute(element, name); + return SemanticVersion.TryParse(raw, out ISemanticVersion? version) ? version : null; } @@ -272,7 +268,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <param name="name">The attribute name.</param> private int? GetAttributeAsNullableInt(HtmlNode element, string name) { - string raw = this.GetAttribute(element, name); + string? raw = this.GetAttribute(element, name); if (raw != null && int.TryParse(raw, out int value)) return value; return null; @@ -281,7 +277,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>Get the text of an element with the given class name.</summary> /// <param name="container">The metadata container.</param> /// <param name="className">The field name.</param> - private string GetInnerHtml(HtmlNode container, string className) + private string? GetInnerHtml(HtmlNode container, string className) { return container.Descendants().FirstOrDefault(p => p.HasClass(className))?.InnerHtml; } @@ -291,8 +287,22 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseModel { + /********* + ** Accessors + *********/ /// <summary>The parse API results.</summary> - public ResponseParseModel Parse { get; set; } + public ResponseParseModel Parse { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="parse">The parse API results.</param> + public ResponseModel(ResponseParseModel parse) + { + this.Parse = parse; + } } /// <summary>The inner response model for the MediaWiki parse API.</summary> @@ -301,8 +311,11 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki [SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Local")] private class ResponseParseModel { + /********* + ** Accessors + *********/ /// <summary>The parsed text.</summary> - public IDictionary<string, string> Text { get; set; } + public IDictionary<string, string> Text { get; } = new Dictionary<string, string>(); } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs index 204acd2b..71c90d0c 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiCompatibilityInfo.cs @@ -7,18 +7,37 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// <summary>The compatibility status.</summary> - public WikiCompatibilityStatus Status { get; set; } + public WikiCompatibilityStatus Status { get; } /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> - public string Summary { get; set; } + public string? Summary { get; } - /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> - public string BrokeIn { get; set; } + /// <summary>The game or SMAPI version which broke this mod, if applicable.</summary> + public string? BrokeIn { get; } /// <summary>The version of the latest unofficial update, if applicable.</summary> - public ISemanticVersion UnofficialVersion { get; set; } + public ISemanticVersion? UnofficialVersion { get; } /// <summary>The URL to the latest unofficial update, if applicable.</summary> - public string UnofficialUrl { get; set; } + public string? UnofficialUrl { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="status">The compatibility status.</param> + /// <param name="summary">The human-readable summary of the compatibility status or workaround, without HTML formatting.</param> + /// <param name="brokeIn">The game or SMAPI version which broke this mod, if applicable.</param> + /// <param name="unofficialVersion">The version of the latest unofficial update, if applicable.</param> + /// <param name="unofficialUrl">The URL to the latest unofficial update, if applicable.</param> + public WikiCompatibilityInfo(WikiCompatibilityStatus status, string? summary, string? brokeIn, ISemanticVersion? unofficialVersion, string? unofficialUrl) + { + this.Status = status; + this.Summary = summary; + this.BrokeIn = brokeIn; + this.UnofficialVersion = unofficialVersion; + this.UnofficialUrl = unofficialUrl; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs index 0587e09d..a6f5a88f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiDataOverrideEntry.cs @@ -1,4 +1,4 @@ -#nullable enable +using System; namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// <summary>The unique mod IDs for the mods to override.</summary> - public string[] Ids { get; set; } = new string[0]; + public string[] Ids { get; set; } = Array.Empty<string>(); /// <summary>Maps local versions to a semantic version for update checks.</summary> public ChangeDescriptor? ChangeLocalVersions { get; set; } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index 4e0104da..fc50125f 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// <summary>A mod entry in the wiki list.</summary> @@ -6,64 +8,114 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /********* ** Accessors *********/ - /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> - public string[] ID { get; set; } + /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order.</summary> + public string[] ID { get; } /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> - public string[] Name { get; set; } + public string[] Name { get; } - /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> - public string[] Author { get; set; } + /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> + public string[] Author { get; } /// <summary>The mod ID on Nexus.</summary> - public int? NexusID { get; set; } + public int? NexusID { get; } /// <summary>The mod ID in the Chucklefish mod repo.</summary> - public int? ChucklefishID { get; set; } + public int? ChucklefishID { get; } /// <summary>The mod ID in the CurseForge mod repo.</summary> - public int? CurseForgeID { get; set; } + public int? CurseForgeID { get; } /// <summary>The mod key in the CurseForge mod repo (used in mod page URLs).</summary> - public string CurseForgeKey { get; set; } + public string? CurseForgeKey { get; } /// <summary>The mod ID in the ModDrop mod repo.</summary> - public int? ModDropID { get; set; } + public int? ModDropID { get; } /// <summary>The GitHub repository in the form 'owner/repo'.</summary> - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; } /// <summary>The URL to a non-GitHub source repo.</summary> - public string CustomSourceUrl { get; set; } + public string? CustomSourceUrl { get; } /// <summary>The custom mod page URL (if applicable).</summary> - public string CustomUrl { get; set; } + public string? CustomUrl { get; } /// <summary>The name of the mod which loads this content pack, if applicable.</summary> - public string ContentPackFor { get; set; } + public string? ContentPackFor { get; } /// <summary>The mod's compatibility with the latest stable version of the game.</summary> - public WikiCompatibilityInfo Compatibility { get; set; } + public WikiCompatibilityInfo Compatibility { get; } /// <summary>The mod's compatibility with the latest beta version of the game (if any).</summary> - public WikiCompatibilityInfo BetaCompatibility { get; set; } + public WikiCompatibilityInfo? BetaCompatibility { get; } /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="BetaCompatibility"/> should be used for beta versions of SMAPI instead of <see cref="Compatibility"/>.</summary> +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(WikiModEntry.BetaCompatibility))] +#endif public bool HasBetaInfo => this.BetaCompatibility != null; /// <summary>The human-readable warnings for players about this mod.</summary> - public string[] Warnings { get; set; } + public string[] Warnings { get; } /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> - public string PullRequestUrl { get; set; } + public string? PullRequestUrl { get; } - /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> - public string DevNote { get; set; } + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests.</summary> + public string? DevNote { get; } /// <summary>The data overrides to apply to the mod's manifest or remote mod page data, if any.</summary> - public WikiDataOverrideEntry Overrides { get; set; } + public WikiDataOverrideEntry? Overrides { get; } /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> - public string Anchor { get; set; } + public string? Anchor { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to oldest order.</param> + /// <param name="name">The mod's display name. If the mod has multiple names, the first one is the most canonical name.</param> + /// <param name="author">The mod's author name. If the author has multiple names, the first one is the most canonical name.</param> + /// <param name="nexusId">The mod ID on Nexus.</param> + /// <param name="chucklefishId">The mod ID in the Chucklefish mod repo.</param> + /// <param name="curseForgeId">The mod ID in the CurseForge mod repo.</param> + /// <param name="curseForgeKey">The mod ID in the CurseForge mod repo.</param> + /// <param name="modDropId">The mod ID in the ModDrop mod repo.</param> + /// <param name="githubRepo">The GitHub repository in the form 'owner/repo'.</param> + /// <param name="customSourceUrl">The URL to a non-GitHub source repo.</param> + /// <param name="customUrl">The custom mod page URL (if applicable).</param> + /// <param name="contentPackFor">The name of the mod which loads this content pack, if applicable.</param> + /// <param name="compatibility">The mod's compatibility with the latest stable version of the game.</param> + /// <param name="betaCompatibility">The mod's compatibility with the latest beta version of the game (if any).</param> + /// <param name="warnings">The human-readable warnings for players about this mod.</param> + /// <param name="pullRequestUrl">The URL of the pull request which submits changes for an unofficial update to the author, if any.</param> + /// <param name="devNote">Special notes intended for developers who maintain unofficial updates or submit pull requests.</param> + /// <param name="overrides">The data overrides to apply to the mod's manifest or remote mod page data, if any.</param> + /// <param name="anchor">The link anchor for the mod entry in the wiki compatibility list.</param> + public WikiModEntry(string[] id, string[] name, string[] author, int? nexusId, int? chucklefishId, int? curseForgeId, string? curseForgeKey, int? modDropId, string? githubRepo, string? customSourceUrl, string? customUrl, string? contentPackFor, WikiCompatibilityInfo compatibility, WikiCompatibilityInfo? betaCompatibility, string[] warnings, string? pullRequestUrl, string? devNote, WikiDataOverrideEntry? overrides, string? anchor) + { + this.ID = id; + this.Name = name; + this.Author = author; + this.NexusID = nexusId; + this.ChucklefishID = chucklefishId; + this.CurseForgeID = curseForgeId; + this.CurseForgeKey = curseForgeKey; + this.ModDropID = modDropId; + this.GitHubRepo = githubRepo; + this.CustomSourceUrl = customSourceUrl; + this.CustomUrl = customUrl; + this.ContentPackFor = contentPackFor; + this.Compatibility = compatibility; + this.BetaCompatibility = betaCompatibility; + this.Warnings = warnings; + this.PullRequestUrl = pullRequestUrl; + this.DevNote = devNote; + this.Overrides = overrides; + this.Anchor = anchor; + } } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs index 0d614f28..24548078 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModList.cs @@ -7,12 +7,27 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki ** Accessors *********/ /// <summary>The stable game version.</summary> - public string StableVersion { get; set; } + public string? StableVersion { get; } /// <summary>The beta game version (if any).</summary> - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /// <summary>The mods on the wiki.</summary> - public WikiModEntry[] Mods { get; set; } + public WikiModEntry[] Mods { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="stableVersion">The stable game version.</param> + /// <param name="betaVersion">The beta game version (if any).</param> + /// <param name="mods">The mods on the wiki.</param> + public WikiModList(string? stableVersion, string? betaVersion, WikiModEntry[] mods) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.Mods = mods; + } } } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs index 8d4198de..8e1538a5 100644 --- a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Xml.Linq; @@ -13,6 +14,7 @@ using Microsoft.Win32; namespace StardewModdingAPI.Toolkit.Framework.GameScanning { /// <summary>Finds installed game folders.</summary> + [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These are valid game install paths.")] public class GameScanner { /********* @@ -39,13 +41,13 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning IEnumerable<string> paths = this .GetCustomInstallPaths() .Concat(this.GetDefaultInstallPaths()) - .Select(PathUtilities.NormalizePath) + .Select(path => PathUtilities.NormalizePath(path)) .Distinct(StringComparer.OrdinalIgnoreCase); // yield valid folders foreach (string path in paths) { - DirectoryInfo folder = new DirectoryInfo(path); + DirectoryInfo folder = new(path); if (this.LooksLikeGameFolder(folder)) yield return folder; } @@ -78,10 +80,12 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning return GameFolderType.NoGameFound; // get assembly version - Version version; + Version? version; try { version = AssemblyName.GetAssemblyName(executable.FullName).Version; + if (version == null) + return GameFolderType.InvalidUnknown; } catch { @@ -121,7 +125,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning case Platform.Linux: case Platform.Mac: { - string home = Environment.GetEnvironmentVariable("HOME"); + string home = Environment.GetEnvironmentVariable("HOME")!; // Linux yield return $"{home}/GOG Games/Stardew Valley/game"; @@ -146,13 +150,13 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning }; foreach (var pair in registryKeys) { - string path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); + string? path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); if (!string.IsNullOrWhiteSpace(path)) yield return path; } // via Steam library path - string steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); + string? steamPath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); if (steamPath != null) yield return Path.Combine(steamPath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); #endif @@ -186,12 +190,12 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning private IEnumerable<string> GetCustomInstallPaths() { // get home path - string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME"); + string homePath = Environment.GetEnvironmentVariable(this.Platform == Platform.Windows ? "USERPROFILE" : "HOME")!; if (string.IsNullOrWhiteSpace(homePath)) yield break; // get targets file - FileInfo file = new FileInfo(Path.Combine(homePath, "stardewvalley.targets")); + FileInfo file = new(Path.Combine(homePath, "stardewvalley.targets")); if (!file.Exists) yield break; @@ -208,7 +212,7 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning } // get install path - XElement element = root.XPathSelectElement("//*[local-name() = 'GamePath']"); // can't use '//GamePath' due to the default namespace + XElement? element = root.XPathSelectElement("//*[local-name() = 'GamePath']"); // can't use '//GamePath' due to the default namespace if (!string.IsNullOrWhiteSpace(element?.Value)) yield return element.Value.Trim(); } @@ -217,27 +221,27 @@ namespace StardewModdingAPI.Toolkit.Framework.GameScanning /// <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> - private string GetLocalMachineRegistryValue(string key, string name) + private string? GetLocalMachineRegistryValue(string key, string name) { RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; - RegistryKey openKey = localMachine.OpenSubKey(key); + RegistryKey? openKey = localMachine.OpenSubKey(key); if (openKey == null) return null; using (openKey) - return (string)openKey.GetValue(name); + return (string?)openKey.GetValue(name); } /// <summary>Get the value of a key in the Windows HKCU registry.</summary> /// <param name="key">The full path of the registry key relative to HKCU.</param> /// <param name="name">The name of the value.</param> - private string GetCurrentUserRegistryValue(string key, string name) + private string? GetCurrentUserRegistryValue(string key, string name) { - RegistryKey currentuser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser; - RegistryKey openKey = currentuser.OpenSubKey(key); + RegistryKey currentUser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser; + RegistryKey? openKey = currentUser.OpenSubKey(key); if (openKey == null) return null; using (openKey) - return (string)openKey.GetValue(name); + return (string?)openKey.GetValue(name); } #endif } diff --git a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs index 8b6eb5fb..6978567e 100644 --- a/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Framework/LowLevelEnvironmentUtility.cs @@ -57,11 +57,13 @@ namespace StardewModdingAPI.Toolkit.Framework #if SMAPI_FOR_WINDOWS try { - return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") + string? result = new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem") .Get() .Cast<ManagementObject>() .Select(entry => entry.GetPropertyValue("Caption").ToString()) .FirstOrDefault(); + + return result ?? "Windows"; } catch { } #endif @@ -98,7 +100,7 @@ namespace StardewModdingAPI.Toolkit.Framework /// </remarks> private static bool IsRunningAndroid() { - using Process process = new Process + using Process process = new() { StartInfo = { @@ -135,7 +137,7 @@ namespace StardewModdingAPI.Toolkit.Framework buffer = Marshal.AllocHGlobal(8192); if (LowLevelEnvironmentUtility.uname(buffer) == 0) { - string os = Marshal.PtrToStringAnsi(buffer); + string? os = Marshal.PtrToStringAnsi(buffer); return os == "Darwin"; } return false; diff --git a/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs index ef6d4dd9..da678ac9 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/MetadataModel.cs @@ -9,6 +9,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData ** Accessors ********/ /// <summary>Extra metadata about mods.</summary> - public IDictionary<string, ModDataModel> ModData { get; set; } + public IDictionary<string, ModDataModel> ModData { get; } = new Dictionary<string, ModDataModel>(); } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs index b02be3e4..9674d283 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataField.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData public bool IsDefault { get; } /// <summary>The lowest version in the range, or <c>null</c> for all past versions.</summary> - public ISemanticVersion LowerVersion { get; } + public ISemanticVersion? LowerVersion { get; } /// <summary>The highest version in the range, or <c>null</c> for all future versions.</summary> - public ISemanticVersion UpperVersion { get; } + public ISemanticVersion? UpperVersion { get; } /********* @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <param name="isDefault">Whether this field should only be applied if it's not already set.</param> /// <param name="lowerVersion">The lowest version in the range, or <c>null</c> for all past versions.</param> /// <param name="upperVersion">The highest version in the range, or <c>null</c> for all future versions.</param> - public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion lowerVersion, ISemanticVersion upperVersion) + public ModDataField(ModDataFieldKey key, string value, bool isDefault, ISemanticVersion? lowerVersion, ISemanticVersion? upperVersion) { this.Key = key; this.Value = value; @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>Get whether this data field applies for the given manifest.</summary> /// <param name="manifest">The mod manifest.</param> - public bool IsMatch(IManifest manifest) + public bool IsMatch(IManifest? manifest) { return manifest?.Version != null // ignore invalid manifest diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs index 2167d3e5..5912fb87 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData ** Accessors *********/ /// <summary>The mod's current unique ID.</summary> - public string ID { get; set; } + public string ID { get; } /// <summary>The former mod IDs (if any).</summary> /// <remarks> @@ -23,14 +23,14 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// ID, if any. If the mod's ID changed over time, multiple variants can be separated by the /// <c>|</c> character. /// </remarks> - public string FormerIDs { get; set; } + public string? FormerIDs { get; } /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary> - public ModWarning SuppressWarnings { get; set; } + public ModWarning SuppressWarnings { get; } /// <summary>This field stores properties that aren't mapped to another field before they're parsed into <see cref="Fields"/>.</summary> [JsonExtensionData] - public IDictionary<string, JToken> ExtensionData { get; set; } + public IDictionary<string, JToken> ExtensionData { get; } = new Dictionary<string, JToken>(); /// <summary>The versioned field data.</summary> /// <remarks> @@ -50,6 +50,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /********* ** Public methods *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's current unique ID.</param> + /// <param name="formerIds">The former mod IDs (if any).</param> + /// <param name="suppressWarnings">The mod warnings to suppress, even if they'd normally be shown.</param> + public ModDataModel(string id, string? formerIds, ModWarning suppressWarnings) + { + this.ID = id; + this.FormerIDs = formerIds; + this.SuppressWarnings = suppressWarnings; + } + /// <summary>Get a parsed representation of the <see cref="Fields"/>.</summary> public IEnumerable<ModDataField> GetFields() { @@ -59,8 +70,8 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData string packedKey = pair.Key; string value = pair.Value; bool isDefault = false; - ISemanticVersion lowerVersion = null; - ISemanticVersion upperVersion = null; + ISemanticVersion? lowerVersion = null; + ISemanticVersion? upperVersion = null; // parse string[] parts = packedKey.Split('|').Select(p => p.Trim()).ToArray(); @@ -111,11 +122,8 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData [OnDeserialized] private void OnDeserialized(StreamingContext context) { - if (this.ExtensionData != null) - { - this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); - this.ExtensionData = null; - } + this.Fields = this.ExtensionData.ToDictionary(p => p.Key, p => p.Value.ToString()); + this.ExtensionData.Clear(); } } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs index 5dd32acf..ab0e4377 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData public string[] FormerIDs { get; } /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary> - public ModWarning SuppressWarnings { get; set; } + public ModWarning SuppressWarnings { get; } /// <summary>The versioned field data.</summary> public ModDataField[] Fields { get; } @@ -70,9 +70,9 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData } /// <summary>Get the default update key for this mod, if any.</summary> - public string GetDefaultUpdateKey() + public string? GetDefaultUpdateKey() { - string updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; + string? updateKey = this.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value; return !string.IsNullOrWhiteSpace(updateKey) ? updateKey : null; @@ -80,9 +80,9 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>Get a parsed representation of the <see cref="ModDataRecord.Fields"/> which match a given manifest.</summary> /// <param name="manifest">The manifest to match.</param> - public ModDataRecordVersionedFields GetVersionedFields(IManifest manifest) + public ModDataRecordVersionedFields GetVersionedFields(IManifest? manifest) { - ModDataRecordVersionedFields parsed = new ModDataRecordVersionedFields { DisplayName = this.DisplayName, DataRecord = this }; + ModDataRecordVersionedFields parsed = new(this); foreach (ModDataField field in this.Fields.Where(field => field.IsMatch(manifest))) { switch (field.Key) diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs index 5aaabd51..65fa424e 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs @@ -7,24 +7,32 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData ** Accessors *********/ /// <summary>The underlying data record.</summary> - public ModDataRecord DataRecord { get; set; } + public ModDataRecord DataRecord { get; } - /// <summary>The default mod name to display when the name isn't available (e.g. during dependency checks).</summary> - public string DisplayName { get; set; } - - /// <summary>The update key to apply.</summary> - public string UpdateKey { get; set; } + /// <summary>The update key to apply (if any).</summary> + public string? UpdateKey { get; set; } /// <summary>The predefined compatibility status.</summary> public ModStatus Status { get; set; } = ModStatus.None; /// <summary>A reason phrase for the <see cref="Status"/>, or <c>null</c> to use the default reason.</summary> - public string StatusReasonPhrase { get; set; } + public string? StatusReasonPhrase { get; set; } /// <summary>Technical details shown in TRACE logs for the <see cref="Status"/>, or <c>null</c> to omit it.</summary> - public string StatusReasonDetails { get; set; } + public string? StatusReasonDetails { get; set; } /// <summary>The upper version for which the <see cref="Status"/> applies (if any).</summary> - public ISemanticVersion StatusUpperVersion { get; set; } + public ISemanticVersion? StatusUpperVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="dataRecord">The underlying data record.</param> + public ModDataRecordVersionedFields(ModDataRecord dataRecord) + { + this.DataRecord = dataRecord; + } } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs index a9da884a..168b8aac 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDatabase.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData private readonly ModDataRecord[] Records; /// <summary>Get an update URL for an update key (if valid).</summary> - private readonly Func<string, string> GetUpdateUrl; + private readonly Func<string, string?> GetUpdateUrl; /********* @@ -22,12 +22,12 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData *********/ /// <summary>Construct an empty instance.</summary> public ModDatabase() - : this(new ModDataRecord[0], key => null) { } + : this(Array.Empty<ModDataRecord>(), _ => null) { } /// <summary>Construct an instance.</summary> /// <param name="records">The underlying mod data records indexed by default display name.</param> /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param> - public ModDatabase(IEnumerable<ModDataRecord> records, Func<string, string> getUpdateUrl) + public ModDatabase(IEnumerable<ModDataRecord> records, Func<string, string?> getUpdateUrl) { this.Records = records.ToArray(); this.GetUpdateUrl = getUpdateUrl; @@ -41,7 +41,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>Get a mod data record.</summary> /// <param name="modID">The unique mod ID.</param> - public ModDataRecord Get(string modID) + public ModDataRecord? Get(string? modID) { return !string.IsNullOrWhiteSpace(modID) ? this.Records.FirstOrDefault(p => p.HasID(modID)) @@ -50,11 +50,11 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>Get the mod page URL for a mod (if available).</summary> /// <param name="id">The unique mod ID.</param> - public string GetModPageUrlFor(string id) + public string? GetModPageUrlFor(string? id) { // get update key - ModDataRecord record = this.Get(id); - ModDataField updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey); + ModDataRecord? record = this.Get(id); + ModDataField? updateKeyField = record?.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey); if (updateKeyField == null) return null; diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index 825b98e5..da2a3c85 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -22,13 +22,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning public ModType Type { get; } /// <summary>The mod manifest.</summary> - public Manifest Manifest { get; } + public Manifest? Manifest { get; } /// <summary>The error which occurred parsing the manifest, if any.</summary> public ModParseError ManifestParseError { get; set; } /// <summary>A human-readable message for the <see cref="ManifestParseError"/>, if any.</summary> - public string ManifestParseErrorText { get; set; } + public string? ManifestParseErrorText { get; set; } /********* @@ -49,7 +49,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <param name="manifest">The mod manifest.</param> /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param> /// <param name="manifestParseErrorText">A human-readable message for the <paramref name="manifestParseError"/>, if any.</param> - public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest, ModParseError manifestParseError, string manifestParseErrorText) + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest? manifest, ModParseError manifestParseError, string? manifestParseErrorText) { // save info this.Directory = directory; @@ -59,9 +59,9 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning this.ManifestParseErrorText = manifestParseErrorText; // set display name - this.DisplayName = manifest?.Name; - if (string.IsNullOrWhiteSpace(this.DisplayName)) - this.DisplayName = PathUtilities.GetRelativePath(root.FullName, directory.FullName); + this.DisplayName = !string.IsNullOrWhiteSpace(manifest?.Name) + ? manifest.Name + : PathUtilities.GetRelativePath(root.FullName, directory.FullName); } /// <summary>Get the update keys for a mod.</summary> diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index e6105f9c..24485620 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization.Models; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { @@ -18,7 +19,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning private readonly JsonHelper JsonHelper; /// <summary>A list of filesystem entry names to ignore when checking whether a folder should be treated as a mod.</summary> - private readonly HashSet<Regex> IgnoreFilesystemNames = new HashSet<Regex> + private readonly HashSet<Regex> IgnoreFilesystemNames = new() { new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager new Regex(@"(?:^\._|^\.DS_Store$|^__MACOSX$|^mcs$)", RegexOptions.Compiled | RegexOptions.IgnoreCase), // macOS @@ -26,7 +27,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning }; /// <summary>A list of file extensions to ignore when searching for mod files.</summary> - private readonly HashSet<string> IgnoreFileExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + private readonly HashSet<string> IgnoreFileExtensions = new(StringComparer.OrdinalIgnoreCase) { // text ".doc", @@ -60,7 +61,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning }; /// <summary>The extensions for packed content files.</summary> - private readonly HashSet<string> StrictXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + private readonly HashSet<string> StrictXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".xgs", ".xnb", @@ -69,7 +70,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning }; /// <summary>The extensions for files which an XNB mod may contain, in addition to <see cref="StrictXnbModExtensions"/>.</summary> - private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + private readonly HashSet<string> PotentialXnbModExtensions = new(StringComparer.OrdinalIgnoreCase) { ".json", ".yaml" @@ -96,7 +97,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <param name="rootPath">The root folder containing mods.</param> public IEnumerable<ModFolder> GetModFolders(string rootPath) { - DirectoryInfo root = new DirectoryInfo(rootPath); + DirectoryInfo root = new(rootPath); return this.GetModFolders(root, root); } @@ -115,7 +116,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning public ModFolder ReadFolder(DirectoryInfo root, DirectoryInfo searchFolder) { // find manifest.json - FileInfo manifestFile = this.FindManifest(searchFolder); + FileInfo? manifestFile = this.FindManifest(searchFolder); // set appropriate invalid-mod error if (manifestFile == null) @@ -137,7 +138,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); // SMAPI installer - if (relevantFiles.Any(p => p.Name == "install on Linux.sh" || p.Name == "install on macOS.command" || p.Name == "install on Windows.bat")) + if (relevantFiles.Any(p => p.Name is "install on Linux.sh" or "install on macOS.command" or "install on Windows.bat")) return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "the SMAPI installer isn't a mod (you can delete this folder after running the installer file)."); // not a mod? @@ -145,13 +146,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning } // read mod info - Manifest manifest = null; + Manifest? manifest = null; ModParseError error = ModParseError.None; - string errorText = null; + string? errorText = null; { try { - if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) + if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest)) { error = ModParseError.ManifestInvalid; errorText = "its manifest is invalid."; @@ -169,14 +170,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning } } - // normalize display fields - if (manifest != null) - { - manifest.Name = this.StripNewlines(manifest.Name); - manifest.Description = this.StripNewlines(manifest.Description); - manifest.Author = this.StripNewlines(manifest.Author); - } - // get mod type ModType type; { @@ -192,7 +185,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning } // build result - return new ModFolder(root, manifestFile.Directory, type, manifest, error, errorText); + return new ModFolder(root, manifestFile.Directory!, type, manifest, error, errorText); } @@ -255,26 +248,26 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> - private FileInfo FindManifest(DirectoryInfo folder) + private FileInfo? FindManifest(DirectoryInfo folder) { - while (true) - { - // check for manifest in current folder - FileInfo file = new FileInfo(Path.Combine(folder.FullName, "manifest.json")); - if (file.Exists) - return file; - - // check for single subfolder - FileSystemInfo[] entries = folder.EnumerateFileSystemInfos().Take(2).ToArray(); - if (entries.Length == 1 && entries[0] is DirectoryInfo subfolder) - { - folder = subfolder; - continue; - } + // check for conventional manifest in current folder + const string defaultName = "manifest.json"; + FileInfo file = new(Path.Combine(folder.FullName, defaultName)); + if (file.Exists) + return file; - // not found - return null; + // check for manifest with incorrect capitalization + { + CaseInsensitivePathLookup pathLookup = new(folder.FullName, SearchOption.TopDirectoryOnly); // don't use GetCachedFor, since we only need it temporarily + string realName = pathLookup.GetFilePath(defaultName); + if (realName != defaultName) + file = new(Path.Combine(folder.FullName, realName)); } + if (file.Exists) + return file; + + // not found + return null; } /// <summary>Get whether a given folder should be treated as a search folder (i.e. look for subfolders containing mods).</summary> @@ -314,8 +307,8 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <param name="entry">The file or folder.</param> private bool IsRelevant(FileSystemInfo entry) { - // ignored file extension - if (entry is FileInfo file && this.IgnoreFileExtensions.Contains(file.Extension)) + // ignored file extensions and any files starting with "." + if ((entry is FileInfo file) && (this.IgnoreFileExtensions.Contains(file.Extension) || file.Name.StartsWith("."))) return false; // ignored entry name @@ -363,12 +356,5 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return hasVortexMarker; } - - /// <summary>Strip newlines from a string.</summary> - /// <param name="input">The input to strip.</param> - private string StripNewlines(string input) - { - return input?.Replace("\r", "").Replace("\n", ""); - } } } diff --git a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs index 489e1c4d..939be771 100644 --- a/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs +++ b/src/SMAPI.Toolkit/Framework/SemanticVersionReader.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Toolkit.Framework { /// <summary>Reads strings into a semantic version.</summary> @@ -16,7 +18,7 @@ namespace StardewModdingAPI.Toolkit.Framework /// <param name="prereleaseTag">An optional prerelease tag.</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> /// <returns>Returns whether the version was successfully parsed.</returns> - public static bool TryParse(string versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) + public static bool TryParse(string? versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) { // init major = 0; @@ -103,7 +105,12 @@ namespace StardewModdingAPI.Toolkit.Framework /// <param name="raw">The raw characters to parse.</param> /// <param name="index">The index of the next character to read.</param> /// <param name="tag">The parsed tag.</param> - private static bool TryParseTag(char[] raw, ref int index, out string tag) + private static bool TryParseTag(char[] raw, ref int index, +#if NET5_0_OR_GREATER + [NotNullWhen(true)] +#endif + out string? tag + ) { // read tag length int length = 0; diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 077c0361..960caf96 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Toolkit.Framework.UpdateData { @@ -15,12 +16,15 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData public ModSiteKey Site { get; } /// <summary>The mod ID within the repository.</summary> - public string ID { get; } + public string? ID { get; } /// <summary>If specified, a substring in download names/descriptions to match.</summary> - public string Subkey { get; } + public string? Subkey { get; } /// <summary>Whether the update key seems to be valid.</summary> +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(UpdateKey.ID))] +#endif public bool LooksValid { get; } @@ -32,9 +36,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <param name="site">The mod site containing the mod.</param> /// <param name="id">The mod ID within the site.</param> /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> - public UpdateKey(string rawText, ModSiteKey site, string id, string subkey) + public UpdateKey(string? rawText, ModSiteKey site, string? id, string? subkey) { - this.RawText = rawText?.Trim(); + this.RawText = rawText?.Trim() ?? string.Empty; this.Site = site; this.ID = id?.Trim(); this.Subkey = subkey?.Trim(); @@ -47,19 +51,19 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <param name="site">The mod site containing the mod.</param> /// <param name="id">The mod ID within the site.</param> /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> - public UpdateKey(ModSiteKey site, string id, string subkey) + public UpdateKey(ModSiteKey site, string? id, string? subkey) : this(UpdateKey.GetString(site, id, subkey), site, id, subkey) { } /// <summary>Parse a raw update key.</summary> /// <param name="raw">The raw update key to parse.</param> - public static UpdateKey Parse(string raw) + public static UpdateKey Parse(string? raw) { // extract site + ID - string rawSite; - string id; + string? rawSite; + string? id; { - string[] parts = raw?.Trim().Split(':'); - if (parts == null || parts.Length != 2) + string[]? parts = raw?.Trim().Split(':'); + if (parts?.Length != 2) return new UpdateKey(raw, ModSiteKey.Unknown, null, null); rawSite = parts[0].Trim(); @@ -69,7 +73,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData id = null; // extract subkey - string subkey = null; + string? subkey = null; if (id != null) { string[] parts = id.Split('@'); @@ -109,7 +113,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <summary>Indicates whether the current object is equal to another object of the same type.</summary> /// <param name="other">An object to compare with this object.</param> - public bool Equals(UpdateKey other) + public bool Equals(UpdateKey? other) { if (!this.LooksValid) { @@ -127,7 +131,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <summary>Determines whether the specified object is equal to the current object.</summary> /// <param name="obj">The object to compare with the current object.</param> - public override bool Equals(object obj) + public override bool Equals(object? obj) { return obj is UpdateKey other && this.Equals(other); } @@ -143,7 +147,7 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <param name="site">The mod site containing the mod.</param> /// <param name="id">The mod ID within the repository.</param> /// <param name="subkey">If specified, a substring in download names/descriptions to match.</param> - public static string GetString(ModSiteKey site, string id, string subkey = null) + public static string GetString(ModSiteKey site, string? id, string? subkey = null) { return $"{site}:{id}{subkey}".Trim(); } diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 38a67ae5..51f6fa24 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Toolkit private readonly string UserAgent; /// <summary>Maps vendor keys (like <c>Nexus</c>) to their mod URL template (where <c>{0}</c> is the mod ID). This doesn't affect update checks, which defer to the remote web API.</summary> - private readonly IDictionary<ModSiteKey, string> VendorModUrls = new Dictionary<ModSiteKey, string>() + private readonly Dictionary<ModSiteKey, string> VendorModUrls = new() { [ModSiteKey.Chucklefish] = "https://community.playstarbound.com/resources/{0}", [ModSiteKey.GitHub] = "https://github.com/{0}/releases", @@ -34,7 +34,7 @@ namespace StardewModdingAPI.Toolkit ** Accessors *********/ /// <summary>Encapsulates SMAPI's JSON parsing.</summary> - public JsonHelper JsonHelper { get; } = new JsonHelper(); + public JsonHelper JsonHelper { get; } = new(); /********* @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Toolkit /// <summary>Construct an instance.</summary> public ModToolkit() { - ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version); + ISemanticVersion version = new SemanticVersion(this.GetType().Assembly.GetName().Version!); this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}"; } @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Toolkit /// <summary>Extract mod metadata from the wiki compatibility list.</summary> public async Task<WikiModList> GetWikiCompatibilityListAsync() { - var client = new WikiClient(this.UserAgent); + WikiClient client = new(this.UserAgent); return await client.FetchModsAsync(); } @@ -87,13 +87,13 @@ namespace StardewModdingAPI.Toolkit /// <summary>Get an update URL for an update key (if valid).</summary> /// <param name="updateKey">The update key.</param> - public string GetUpdateUrl(string updateKey) + public string? GetUpdateUrl(string updateKey) { UpdateKey parsed = UpdateKey.Parse(updateKey); if (!parsed.LooksValid) return null; - if (this.VendorModUrls.TryGetValue(parsed.Site, out string urlTemplate)) + if (this.VendorModUrls.TryGetValue(parsed.Site, out string? urlTemplate)) return string.Format(urlTemplate, parsed.ID); return null; diff --git a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs index eede4562..6f5dffbe 100644 --- a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs +++ b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs @@ -2,4 +2,5 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("StardewModdingAPI")] [assembly: InternalsVisibleTo("SMAPI.Installer")] +[assembly: InternalsVisibleTo("SMAPI.Tests")] [assembly: InternalsVisibleTo("SMAPI.Web")] diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index 2f3e282b..3713758f 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Text.RegularExpressions; using StardewModdingAPI.Toolkit.Framework; @@ -38,10 +39,10 @@ namespace StardewModdingAPI.Toolkit public int PlatformRelease { get; } /// <inheritdoc /> - public string PrereleaseTag { get; } + public string? PrereleaseTag { get; } /// <inheritdoc /> - public string BuildMetadata { get; } + public string? BuildMetadata { get; } /********* @@ -54,7 +55,7 @@ namespace StardewModdingAPI.Toolkit /// <param name="platformRelease">The platform-specific version (if applicable).</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> - public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string prereleaseTag = null, string buildMetadata = null) + public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string? prereleaseTag = null, string? buildMetadata = null) { this.MajorVersion = major; this.MinorVersion = minor; @@ -90,7 +91,7 @@ namespace StardewModdingAPI.Toolkit { if (version == null) throw new ArgumentNullException(nameof(version), "The input version string can't be null."); - if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) || (!allowNonStandard && platformRelease != 0)) + if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string? prereleaseTag, out string? buildMetadata) || (!allowNonStandard && platformRelease != 0)) throw new FormatException($"The input '{version}' isn't a valid semantic version."); this.MajorVersion = major; @@ -104,59 +105,77 @@ namespace StardewModdingAPI.Toolkit } /// <inheritdoc /> - public int CompareTo(ISemanticVersion other) + public int CompareTo(ISemanticVersion? other) { - if (other == null) - throw new ArgumentNullException(nameof(other)); - return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); + return other == null + ? 1 + : this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag); } /// <inheritdoc /> - public bool Equals(ISemanticVersion other) + public bool Equals(ISemanticVersion? other) { return other != null && this.CompareTo(other) == 0; } /// <inheritdoc /> +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] +#endif public bool IsPrerelease() { return !string.IsNullOrWhiteSpace(this.PrereleaseTag); } /// <inheritdoc /> - public bool IsOlderThan(ISemanticVersion other) + public bool IsOlderThan(ISemanticVersion? other) { return this.CompareTo(other) < 0; } /// <inheritdoc /> - public bool IsOlderThan(string other) + public bool IsOlderThan(string? other) { - return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true)); + ISemanticVersion? otherVersion = other != null + ? new SemanticVersion(other, allowNonStandard: true) + : null; + + return this.IsOlderThan(otherVersion); } /// <inheritdoc /> - public bool IsNewerThan(ISemanticVersion other) + public bool IsNewerThan(ISemanticVersion? other) { return this.CompareTo(other) > 0; } /// <inheritdoc /> - public bool IsNewerThan(string other) + public bool IsNewerThan(string? other) { - return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true)); + ISemanticVersion? otherVersion = other != null + ? new SemanticVersion(other, allowNonStandard: true) + : null; + + return this.IsNewerThan(otherVersion); } /// <inheritdoc /> - public bool IsBetween(ISemanticVersion min, ISemanticVersion max) + public bool IsBetween(ISemanticVersion? min, ISemanticVersion? max) { return this.CompareTo(min) >= 0 && this.CompareTo(max) <= 0; } /// <inheritdoc /> - public bool IsBetween(string min, string max) + public bool IsBetween(string? min, string? max) { - return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true)); + ISemanticVersion? minVersion = min != null + ? new SemanticVersion(min, allowNonStandard: true) + : null; + ISemanticVersion? maxVersion = max != null + ? new SemanticVersion(max, allowNonStandard: true) + : null; + + return this.IsBetween(minVersion, maxVersion); } /// <inheritdoc cref="ISemanticVersion.ToString" /> @@ -182,7 +201,12 @@ namespace StardewModdingAPI.Toolkit /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> - public static bool TryParse(string version, out ISemanticVersion parsed) + public static bool TryParse(string? version, +#if NET5_0_OR_GREATER + [NotNullWhen(true)] +#endif + out ISemanticVersion? parsed + ) { return SemanticVersion.TryParse(version, allowNonStandard: false, out parsed); } @@ -192,8 +216,19 @@ namespace StardewModdingAPI.Toolkit /// <param name="allowNonStandard">Whether to allow non-standard extensions to semantic versioning.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> - public static bool TryParse(string version, bool allowNonStandard, out ISemanticVersion parsed) + public static bool TryParse(string? version, bool allowNonStandard, +#if NET5_0_OR_GREATER + [NotNullWhen(true)] +#endif + out ISemanticVersion? parsed + ) { + if (version == null) + { + parsed = null; + return false; + } + try { parsed = new SemanticVersion(version, allowNonStandard); @@ -212,7 +247,7 @@ namespace StardewModdingAPI.Toolkit *********/ /// <summary>Get a normalized prerelease or build tag.</summary> /// <param name="tag">The tag to normalize.</param> - private string GetNormalizedTag(string tag) + private string? GetNormalizedTag(string? tag) { tag = tag?.Trim(); return !string.IsNullOrWhiteSpace(tag) ? tag : null; @@ -224,7 +259,7 @@ namespace StardewModdingAPI.Toolkit /// <param name="otherPatch">The patch version to compare with this instance.</param> /// <param name="otherPlatformRelease">The non-standard platform release to compare with this instance.</param> /// <param name="otherTag">The prerelease tag to compare with this instance.</param> - private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string otherTag) + private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string? otherTag) { const int same = 0; const int curNewer = 1; @@ -253,8 +288,8 @@ namespace StardewModdingAPI.Toolkit return curOlder; // compare two prerelease tag values - string[] curParts = this.PrereleaseTag.Split('.', '-'); - string[] otherParts = otherTag.Split('.', '-'); + string[] curParts = this.PrereleaseTag?.Split('.', '-') ?? Array.Empty<string>(); + string[] otherParts = otherTag?.Split('.', '-') ?? Array.Empty<string>(); int length = Math.Max(curParts.Length, otherParts.Length); for (int i = 0; i < length; i++) { diff --git a/src/SMAPI.Toolkit/SemanticVersionComparer.cs b/src/SMAPI.Toolkit/SemanticVersionComparer.cs index 9f6b57a2..2eca30df 100644 --- a/src/SMAPI.Toolkit/SemanticVersionComparer.cs +++ b/src/SMAPI.Toolkit/SemanticVersionComparer.cs @@ -3,20 +3,20 @@ using System.Collections.Generic; namespace StardewModdingAPI.Toolkit { /// <summary>A comparer for semantic versions based on the <see cref="SemanticVersion.CompareTo(ISemanticVersion)"/> field.</summary> - public class SemanticVersionComparer : IComparer<ISemanticVersion> + public class SemanticVersionComparer : IComparer<ISemanticVersion?> { /********* ** Accessors *********/ /// <summary>A singleton instance of the comparer.</summary> - public static SemanticVersionComparer Instance { get; } = new SemanticVersionComparer(); + public static SemanticVersionComparer Instance { get; } = new(); /********* ** Public methods *********/ /// <inheritdoc /> - public int Compare(ISemanticVersion x, ISemanticVersion y) + public int Compare(ISemanticVersion? x, ISemanticVersion? y) { if (object.ReferenceEquals(x, y)) return 0; diff --git a/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs index 5cabe9d8..faaeedea 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="objectType">The object type.</param> /// <param name="existingValue">The object being read.</param> /// <param name="serializer">The calling serializer.</param> - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { return serializer.Deserialize<ManifestContentPackFor>(reader); } @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="writer">The JSON writer.</param> /// <param name="value">The value.</param> /// <param name="serializer">The calling serializer.</param> - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { throw new InvalidOperationException("This converter does not write JSON."); } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs index 7b88d6b7..c499a2c6 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs @@ -35,13 +35,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="objectType">The object type.</param> /// <param name="existingValue">The object being read.</param> /// <param name="serializer">The calling serializer.</param> - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { List<ManifestDependency> result = new List<ManifestDependency>(); foreach (JObject obj in JArray.Load(reader).Children<JObject>()) { - string uniqueID = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.UniqueID)); - string minVersion = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.MinimumVersion)); + string uniqueID = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.UniqueID))!; // will be validated separately if null + string? minVersion = obj.ValueIgnoreCase<string>(nameof(ManifestDependency.MinimumVersion)); bool required = obj.ValueIgnoreCase<bool?>(nameof(ManifestDependency.IsRequired)) ?? true; result.Add(new ManifestDependency(uniqueID, minVersion, required)); } @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="writer">The JSON writer.</param> /// <param name="value">The value.</param> /// <param name="serializer">The calling serializer.</param> - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { throw new InvalidOperationException("This converter does not write JSON."); } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index cf69104d..c32c3185 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -39,15 +39,17 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="objectType">The object type.</param> /// <param name="existingValue">The object being read.</param> /// <param name="serializer">The calling serializer.</param> - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { string path = reader.Path; switch (reader.TokenType) { case JsonToken.StartObject: return this.ReadObject(JObject.Load(reader)); + case JsonToken.String: return this.ReadString(JToken.Load(reader).Value<string>(), path); + default: throw new SParseException($"Can't parse {nameof(ISemanticVersion)} from {reader.TokenType} node (path: {reader.Path})."); } @@ -57,7 +59,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <param name="writer">The JSON writer.</param> /// <param name="value">The value.</param> /// <param name="serializer">The calling serializer.</param> - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { writer.WriteValue(value?.ToString()); } @@ -73,7 +75,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters int major = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MajorVersion)); int minor = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MinorVersion)); int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion)); - string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); + string? prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag); } @@ -81,11 +83,11 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters /// <summary>Read a JSON string.</summary> /// <param name="str">The JSON string value.</param> /// <param name="path">The path to the current JSON node.</param> - private ISemanticVersion ReadString(string str, string path) + private ISemanticVersion? ReadString(string str, string path) { if (string.IsNullOrWhiteSpace(str)) return null; - if (!SemanticVersion.TryParse(str, allowNonStandard: this.AllowNonStandard, out ISemanticVersion version)) + if (!SemanticVersion.TryParse(str, allowNonStandard: this.AllowNonStandard, out ISemanticVersion? version)) throw new SParseException($"Can't parse semantic version from invalid value '{str}', should be formatted like 1.2, 1.2.30, or 1.2.30-beta (path: {path})."); return version; } diff --git a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs index ccc5158b..1c59f5e7 100644 --- a/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs @@ -25,21 +25,12 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters return objectType == typeof(T) || Nullable.GetUnderlyingType(objectType) == typeof(T); } - /// <summary>Writes the JSON representation of the object.</summary> - /// <param name="writer">The JSON writer.</param> - /// <param name="value">The value.</param> - /// <param name="serializer">The calling serializer.</param> - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) - { - throw new InvalidOperationException("This converter does not write JSON."); - } - /// <summary>Reads the JSON representation of the object.</summary> /// <param name="reader">The JSON reader.</param> /// <param name="objectType">The object type.</param> /// <param name="existingValue">The object being read.</param> /// <param name="serializer">The calling serializer.</param> - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { string path = reader.Path; switch (reader.TokenType) @@ -58,6 +49,15 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters } } + /// <summary>Writes the JSON representation of the object.</summary> + /// <param name="writer">The JSON writer.</param> + /// <param name="value">The value.</param> + /// <param name="serializer">The calling serializer.</param> + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) + { + throw new InvalidOperationException("This converter does not write JSON."); + } + /********* ** Protected methods diff --git a/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs index 10f88dde..78297035 100644 --- a/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs +++ b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs @@ -10,12 +10,12 @@ namespace StardewModdingAPI.Toolkit.Serialization /// <typeparam name="T">The value type.</typeparam> /// <param name="obj">The JSON object to search.</param> /// <param name="fieldName">The field name.</param> - public static T ValueIgnoreCase<T>(this JObject obj, string fieldName) + public static T? ValueIgnoreCase<T>(this JObject obj, string fieldName) { - JToken token = obj.GetValue(fieldName, StringComparison.OrdinalIgnoreCase); + JToken? token = obj.GetValue(fieldName, StringComparison.OrdinalIgnoreCase); return token != null ? token.Value<T>() - : default(T); + : default; } } } diff --git a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index 00db9903..3c9308f2 100644 --- a/src/SMAPI.Toolkit/Serialization/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -14,7 +15,7 @@ namespace StardewModdingAPI.Toolkit.Serialization ** Accessors *********/ /// <summary>The JSON settings to use when serializing and deserializing files.</summary> - public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings + public JsonSerializerSettings JsonSettings { get; } = new() { Formatting = Formatting.Indented, ObjectCreationHandling = ObjectCreationHandling.Replace, // avoid issue where default ICollection<T> values are duplicated each time the config is loaded @@ -36,7 +37,12 @@ namespace StardewModdingAPI.Toolkit.Serialization /// <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) + public bool ReadJsonFileIfExists<TModel>(string fullPath, +#if NET5_0_OR_GREATER + [NotNullWhen(true)] +#endif + out TModel? result + ) { // validate if (string.IsNullOrWhiteSpace(fullPath)) @@ -48,9 +54,9 @@ namespace StardewModdingAPI.Toolkit.Serialization { json = File.ReadAllText(fullPath); } - catch (Exception ex) when (ex is DirectoryNotFoundException || ex is FileNotFoundException) + catch (Exception ex) when (ex is DirectoryNotFoundException or FileNotFoundException) { - result = default(TModel); + result = default; return false; } @@ -58,7 +64,7 @@ namespace StardewModdingAPI.Toolkit.Serialization try { result = this.Deserialize<TModel>(json); - return true; + return result != null; } catch (Exception ex) { @@ -88,7 +94,7 @@ namespace StardewModdingAPI.Toolkit.Serialization throw new ArgumentException("The file path is empty or invalid.", nameof(fullPath)); // create directory if needed - string dir = Path.GetDirectoryName(fullPath); + string dir = Path.GetDirectoryName(fullPath)!; if (dir == null) throw new ArgumentException("The file path is invalid.", nameof(fullPath)); if (!Directory.Exists(dir)) @@ -106,7 +112,8 @@ namespace StardewModdingAPI.Toolkit.Serialization { try { - return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings); + return JsonConvert.DeserializeObject<TModel>(json, this.JsonSettings) + ?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON."); } catch (JsonReaderException) { @@ -115,7 +122,8 @@ namespace StardewModdingAPI.Toolkit.Serialization { try { - return JsonConvert.DeserializeObject<TModel>(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings); + return JsonConvert.DeserializeObject<TModel>(json.Replace('“', '"').Replace('”', '"'), this.JsonSettings) + ?? throw new InvalidOperationException($"Couldn't deserialize model type '{typeof(TModel)}' from empty or null JSON."); } catch { /* rethrow original error */ } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 46b654a5..da3ad608 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -1,5 +1,6 @@ +using System; using System.Collections.Generic; -using System.Runtime.Serialization; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Serialization.Converters; @@ -12,48 +13,45 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>A brief description of the mod.</summary> - public string Description { get; set; } + public string Description { get; } /// <summary>The mod author's name.</summary> - public string Author { get; set; } + public string Author { get; } /// <summary>The mod version.</summary> - public ISemanticVersion Version { get; set; } + public ISemanticVersion Version { get; } /// <summary>The minimum SMAPI version required by this mod, if any.</summary> - public ISemanticVersion MinimumApiVersion { get; set; } + public ISemanticVersion? MinimumApiVersion { get; } /// <summary>The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</summary> - public string EntryDll { get; set; } + public string? EntryDll { get; } /// <summary>The mod which will read this as a content pack. Mutually exclusive with <see cref="Manifest.EntryDll"/>.</summary> [JsonConverter(typeof(ManifestContentPackForConverter))] - public IManifestContentPackFor ContentPackFor { get; set; } + public IManifestContentPackFor? ContentPackFor { get; } /// <summary>The other mods that must be loaded before this mod.</summary> [JsonConverter(typeof(ManifestDependencyArrayConverter))] - public IManifestDependency[] Dependencies { get; set; } + public IManifestDependency[] Dependencies { get; } /// <summary>The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</summary> - public string[] UpdateKeys { get; set; } + public string[] UpdateKeys { get; private set; } /// <summary>The unique mod ID.</summary> - public string UniqueID { get; set; } + public string UniqueID { get; } /// <summary>Any manifest fields which didn't match a valid field.</summary> [JsonExtensionData] - public IDictionary<string, object> ExtraFields { get; set; } + public IDictionary<string, object> ExtraFields { get; } = new Dictionary<string, object>(); /********* ** Public methods *********/ - /// <summary>Construct an instance.</summary> - public Manifest() { } - /// <summary>Construct an instance for a transitional content pack.</summary> /// <param name="uniqueID">The unique mod ID.</param> /// <param name="name">The mod name.</param> @@ -61,24 +59,71 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models /// <param name="description">A brief description of the mod.</param> /// <param name="version">The mod version.</param> /// <param name="contentPackFor">The modID which will read this as a content pack.</param> - public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string contentPackFor = null) + public Manifest(string uniqueID, string name, string author, string description, ISemanticVersion version, string? contentPackFor = null) + : this( + uniqueId: uniqueID, + name: name, + author: author, + description: description, + version: version, + minimumApiVersion: null, + entryDll: null, + contentPackFor: contentPackFor != null + ? new ManifestContentPackFor(contentPackFor, null) + : null, + dependencies: null, + updateKeys: null + ) + { } + + /// <summary>Construct an instance for a transitional content pack.</summary> + /// <param name="uniqueId">The unique mod ID.</param> + /// <param name="name">The mod name.</param> + /// <param name="author">The mod author's name.</param> + /// <param name="description">A brief description of the mod.</param> + /// <param name="version">The mod version.</param> + /// <param name="minimumApiVersion">The minimum SMAPI version required by this mod, if any.</param> + /// <param name="entryDll">The name of the DLL in the directory that has the <c>Entry</c> method. Mutually exclusive with <see cref="ContentPackFor"/>.</param> + /// <param name="contentPackFor">The modID which will read this as a content pack.</param> + /// <param name="dependencies">The other mods that must be loaded before this mod.</param> + /// <param name="updateKeys">The namespaced mod IDs to query for updates (like <c>Nexus:541</c>).</param> + [JsonConstructor] + public Manifest(string uniqueId, string name, string author, string description, ISemanticVersion version, ISemanticVersion? minimumApiVersion, string? entryDll, IManifestContentPackFor? contentPackFor, IManifestDependency[]? dependencies, string[]? updateKeys) { - this.Name = name; - this.Author = author; - this.Description = description; + this.UniqueID = this.NormalizeWhitespace(uniqueId); + this.Name = this.NormalizeWhitespace(name); + this.Author = this.NormalizeWhitespace(author); + this.Description = this.NormalizeWhitespace(description); this.Version = version; - this.UniqueID = uniqueID; - this.UpdateKeys = new string[0]; - this.ContentPackFor = new ManifestContentPackFor { UniqueID = contentPackFor }; + this.MinimumApiVersion = minimumApiVersion; + this.EntryDll = this.NormalizeWhitespace(entryDll); + this.ContentPackFor = contentPackFor; + this.Dependencies = dependencies ?? Array.Empty<IManifestDependency>(); + this.UpdateKeys = updateKeys ?? Array.Empty<string>(); + } + + /// <summary>Override the update keys loaded from the mod info.</summary> + /// <param name="updateKeys">The new update keys to set.</param> + internal void OverrideUpdateKeys(params string[] updateKeys) + { + this.UpdateKeys = updateKeys; } - /// <summary>Normalize the model after it's deserialized.</summary> - /// <param name="context">The deserialization context.</param> - [OnDeserialized] - public void OnDeserialized(StreamingContext context) + + /********* + ** Private methods + *********/ + /// <summary>Normalize whitespace in a raw string.</summary> + /// <param name="input">The input to strip.</param> +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("input")] +#endif + private string? NormalizeWhitespace(string? input) { - this.Dependencies ??= new IManifestDependency[0]; - this.UpdateKeys ??= new string[0]; + return input + ?.Trim() + .Replace("\r", "") + .Replace("\n", ""); } } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs index 1eb80889..f7dc8aa8 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Toolkit.Serialization.Models { /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> @@ -7,9 +9,36 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models ** Accessors *********/ /// <summary>The unique ID of the mod which can read this content pack.</summary> - public string UniqueID { get; set; } + public string UniqueID { get; } /// <summary>The minimum required version (if any).</summary> - public ISemanticVersion MinimumVersion { get; set; } + public ISemanticVersion? MinimumVersion { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="uniqueId">The unique ID of the mod which can read this content pack.</param> + /// <param name="minimumVersion">The minimum required version (if any).</param> + public ManifestContentPackFor(string uniqueId, ISemanticVersion? minimumVersion) + { + this.UniqueID = this.NormalizeWhitespace(uniqueId); + this.MinimumVersion = minimumVersion; + } + + + /********* + ** Private methods + *********/ + /// <summary>Normalize whitespace in a raw string.</summary> + /// <param name="input">The input to strip.</param> +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("input")] +#endif + private string? NormalizeWhitespace(string? input) + { + return input?.Trim(); + } } } diff --git a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs index 00f168f4..fa254ea7 100644 --- a/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs @@ -1,3 +1,6 @@ +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; + namespace StardewModdingAPI.Toolkit.Serialization.Models { /// <summary>A mod dependency listed in a mod manifest.</summary> @@ -7,13 +10,13 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models ** Accessors *********/ /// <summary>The unique mod ID to require.</summary> - public string UniqueID { get; set; } + public string UniqueID { get; } /// <summary>The minimum required version (if any).</summary> - public ISemanticVersion MinimumVersion { get; set; } + public ISemanticVersion? MinimumVersion { get; } /// <summary>Whether the dependency must be installed to use the mod.</summary> - public bool IsRequired { get; set; } + public bool IsRequired { get; } /********* @@ -23,13 +26,40 @@ namespace StardewModdingAPI.Toolkit.Serialization.Models /// <param name="uniqueID">The unique mod ID to require.</param> /// <param name="minimumVersion">The minimum required version (if any).</param> /// <param name="required">Whether the dependency must be installed to use the mod.</param> - public ManifestDependency(string uniqueID, string minimumVersion, bool required = true) + public ManifestDependency(string uniqueID, string? minimumVersion, bool required = true) + : this( + uniqueID: uniqueID, + minimumVersion: !string.IsNullOrWhiteSpace(minimumVersion) + ? new SemanticVersion(minimumVersion) + : null, + required: required + ) + { } + + /// <summary>Construct an instance.</summary> + /// <param name="uniqueID">The unique mod ID to require.</param> + /// <param name="minimumVersion">The minimum required version (if any).</param> + /// <param name="required">Whether the dependency must be installed to use the mod.</param> + [JsonConstructor] + public ManifestDependency(string uniqueID, ISemanticVersion? minimumVersion, bool required = true) { - this.UniqueID = uniqueID; - this.MinimumVersion = !string.IsNullOrWhiteSpace(minimumVersion) - ? new SemanticVersion(minimumVersion) - : null; + this.UniqueID = this.NormalizeWhitespace(uniqueID); + this.MinimumVersion = minimumVersion; this.IsRequired = required; } + + + /********* + ** Private methods + *********/ + /// <summary>Normalize whitespace in a raw string.</summary> + /// <param name="input">The input to strip.</param> +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("input")] +#endif + private string? NormalizeWhitespace(string? input) + { + return input?.Trim(); + } } } diff --git a/src/SMAPI.Toolkit/Serialization/SParseException.cs b/src/SMAPI.Toolkit/Serialization/SParseException.cs index 5f58b5b8..c2b3f68e 100644 --- a/src/SMAPI.Toolkit/Serialization/SParseException.cs +++ b/src/SMAPI.Toolkit/Serialization/SParseException.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI.Toolkit.Serialization /// <summary>Construct an instance.</summary> /// <param name="message">The error message.</param> /// <param name="ex">The underlying exception, if any.</param> - public SParseException(string message, Exception ex = null) + public SParseException(string message, Exception? ex = null) : base(message, ex) { } } } diff --git a/src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs b/src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs new file mode 100644 index 00000000..12fad008 --- /dev/null +++ b/src/SMAPI.Toolkit/Utilities/CaseInsensitivePathLookup.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace StardewModdingAPI.Toolkit.Utilities +{ + /// <summary>Provides an API for case-insensitive relative path lookups within a root directory.</summary> + internal class CaseInsensitivePathLookup + { + /********* + ** Fields + *********/ + /// <summary>The root directory path for relative paths.</summary> + private readonly string RootPath; + + /// <summary>A case-insensitive lookup of file paths within the <see cref="RootPath"/>. Each path is listed in both file path and asset name format, so it's usable in both contexts without needing to re-parse paths.</summary> + private readonly Lazy<Dictionary<string, string>> RelativePathCache; + + /// <summary>The case-insensitive path caches by root path.</summary> + private static readonly Dictionary<string, CaseInsensitivePathLookup> CachedRoots = new(StringComparer.OrdinalIgnoreCase); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="rootPath">The root directory path for relative paths.</param> + /// <param name="searchOption">Which directories to scan from the root.</param> + public CaseInsensitivePathLookup(string rootPath, SearchOption searchOption = SearchOption.AllDirectories) + { + this.RootPath = rootPath; + this.RelativePathCache = new(() => this.GetRelativePathCache(searchOption)); + } + + /// <summary>Get the exact capitalization for a given relative file path.</summary> + /// <param name="relativePath">The relative path.</param> + /// <remarks>Returns the resolved path in file path format, else the normalized <paramref name="relativePath"/>.</remarks> + public string GetFilePath(string relativePath) + { + return this.GetImpl(PathUtilities.NormalizePath(relativePath)); + } + + /// <summary>Get the exact capitalization for a given asset name.</summary> + /// <param name="relativePath">The relative path.</param> + /// <remarks>Returns the resolved path in asset name format, else the normalized <paramref name="relativePath"/>.</remarks> + public string GetAssetName(string relativePath) + { + return this.GetImpl(PathUtilities.NormalizeAssetName(relativePath)); + } + + /// <summary>Add a relative path that was just created by a SMAPI API.</summary> + /// <param name="relativePath">The relative path. This must already be normalized in asset name or file path format.</param> + public void Add(string relativePath) + { + // skip if cache isn't created yet (no need to add files manually in that case) + if (!this.RelativePathCache.IsValueCreated) + return; + + // skip if already cached + if (this.RelativePathCache.Value.ContainsKey(relativePath)) + return; + + // make sure path exists + relativePath = PathUtilities.NormalizePath(relativePath); + if (!File.Exists(Path.Combine(this.RootPath, relativePath))) + throw new InvalidOperationException($"Can't add relative path '{relativePath}' to the case-insensitive cache for '{this.RootPath}' because that file doesn't exist."); + + // cache path + this.CacheRawPath(this.RelativePathCache.Value, relativePath); + } + + /// <summary>Get a cached dictionary of relative paths within a root path, for case-insensitive file lookups.</summary> + /// <param name="rootPath">The root path to scan.</param> + public static CaseInsensitivePathLookup GetCachedFor(string rootPath) + { + rootPath = PathUtilities.NormalizePath(rootPath); + + if (!CaseInsensitivePathLookup.CachedRoots.TryGetValue(rootPath, out CaseInsensitivePathLookup? cache)) + CaseInsensitivePathLookup.CachedRoots[rootPath] = cache = new CaseInsensitivePathLookup(rootPath); + + return cache; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the exact capitalization for a given relative path.</summary> + /// <param name="relativePath">The relative path. This must already be normalized into asset name or file path format (i.e. using <see cref="PathUtilities.NormalizeAssetName"/> or <see cref="PathUtilities.NormalizePath"/> respectively).</param> + /// <remarks>Returns the resolved path in the same format if found, else returns the path as-is.</remarks> + private string GetImpl(string relativePath) + { + // invalid path + if (string.IsNullOrWhiteSpace(relativePath)) + return relativePath; + + // already cached + if (this.RelativePathCache.Value.TryGetValue(relativePath, out string? resolved)) + return resolved; + + // keep capitalization as-is + if (File.Exists(Path.Combine(this.RootPath, relativePath))) + { + // file exists but isn't cached for some reason + // cache it now so any later references to it are case-insensitive + this.CacheRawPath(this.RelativePathCache.Value, relativePath); + } + return relativePath; + } + + /// <summary>Get a case-insensitive lookup of file paths (see <see cref="RelativePathCache"/>).</summary> + /// <param name="searchOption">Which directories to scan from the root.</param> + private Dictionary<string, string> GetRelativePathCache(SearchOption searchOption) + { + Dictionary<string, string> cache = new(StringComparer.OrdinalIgnoreCase); + + foreach (string path in Directory.EnumerateFiles(this.RootPath, "*", searchOption)) + { + string relativePath = path.Substring(this.RootPath.Length + 1); + + this.CacheRawPath(cache, relativePath); + } + + return cache; + } + + /// <summary>Add a raw relative path to the cache.</summary> + /// <param name="cache">The cache to update.</param> + /// <param name="relativePath">The relative path to cache, with its exact filesystem capitalization.</param> + private void CacheRawPath(IDictionary<string, string> cache, string relativePath) + { + string filePath = PathUtilities.NormalizePath(relativePath); + string assetName = PathUtilities.NormalizeAssetName(relativePath); + + cache[filePath] = filePath; + cache[assetName] = assetName; + } + } +} diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index 7536337a..1791c5b3 100644 --- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework; namespace StardewModdingAPI.Toolkit.Utilities @@ -34,7 +33,6 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get the human-readable OS name and version.</summary> /// <param name="platform">The current platform.</param> - [SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")] public static string GetFriendlyPlatformName(Platform platform) { return LowLevelEnvironmentUtility.GetFriendlyPlatformName(platform.ToString()); diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index 2e9e5eac..136279f2 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.IO; using System.Linq; @@ -36,8 +37,11 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <param name="path">The path to split.</param> /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> [Pure] - public static string[] GetSegments(string path, int? limit = null) + public static string[] GetSegments(string? path, int? limit = null) { + if (path == null) + return Array.Empty<string>(); + return limit.HasValue ? path.Split(PathUtilities.PossiblePathSeparators, limit.Value, StringSplitOptions.RemoveEmptyEntries) : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); @@ -45,8 +49,16 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Normalize an asset name to match how MonoGame's content APIs would normalize and cache it.</summary> /// <param name="assetName">The asset name to normalize.</param> - public static string NormalizeAssetName(string assetName) + [Pure] +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("assetName")] +#endif + public static string? NormalizeAssetName(string? assetName) { + assetName = assetName?.Trim(); + if (string.IsNullOrEmpty(assetName)) + return assetName; + return string.Join(PathUtilities.PreferredAssetSeparator.ToString(), PathUtilities.GetSegments(assetName)); // based on MonoGame's ContentManager.Load<T> logic } @@ -54,7 +66,10 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <param name="path">The file path to normalize.</param> /// <remarks>This should only be used for file paths. For asset names, use <see cref="NormalizeAssetName"/> instead.</remarks> [Pure] - public static string NormalizePath(string path) +#if NET5_0_OR_GREATER + [return: NotNullIfNotNull("path")] +#endif + public static string? NormalizePath(string? path) { path = path?.Trim(); if (string.IsNullOrEmpty(path)) @@ -100,8 +115,8 @@ namespace StardewModdingAPI.Toolkit.Utilities // though, this is only for compatibility with the mod build package. // convert to URIs - Uri from = new Uri(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); - Uri to = new Uri(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri from = new(sourceDir.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); + Uri to = new(targetPath.TrimEnd(PathUtilities.PossiblePathSeparators) + "/"); if (from.Scheme != to.Scheme) throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); @@ -132,7 +147,7 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> /// <param name="path">The path to check.</param> [Pure] - public static bool IsSafeRelativePath(string path) + public static bool IsSafeRelativePath(string? path) { if (string.IsNullOrWhiteSpace(path)) return true; @@ -145,9 +160,11 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> /// <param name="str">The string to check.</param> [Pure] - public static bool IsSlug(string str) + public static bool IsSlug(string? str) { - return !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); + return + string.IsNullOrWhiteSpace(str) + || !Regex.IsMatch(str, "[^a-z0-9_.-]", RegexOptions.IgnoreCase); } } } diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs index 64bd5ca5..49356f76 100644 --- a/src/SMAPI.Web/BackgroundService.cs +++ b/src/SMAPI.Web/BackgroundService.cs @@ -19,13 +19,17 @@ namespace StardewModdingAPI.Web ** Fields *********/ /// <summary>The background task server.</summary> - private static BackgroundJobServer JobServer; + private static BackgroundJobServer? JobServer; /// <summary>The cache in which to store wiki metadata.</summary> - private static IWikiCacheRepository WikiCache; + private static IWikiCacheRepository? WikiCache; /// <summary>The cache in which to store mod data.</summary> - private static IModCacheRepository ModCache; + private static IModCacheRepository? ModCache; + + /// <summary>Whether the service has been started.</summary> + [MemberNotNullWhen(true, nameof(BackgroundService.JobServer), nameof(BackgroundService.WikiCache), nameof(BackgroundService.ModCache))] + private static bool IsStarted { get; set; } /********* @@ -59,6 +63,8 @@ namespace StardewModdingAPI.Web RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly + BackgroundService.IsStarted = true; + return Task.CompletedTask; } @@ -66,6 +72,8 @@ namespace StardewModdingAPI.Web /// <param name="cancellationToken">Tracks whether the shutdown process should no longer be graceful.</param> public async Task StopAsync(CancellationToken cancellationToken) { + BackgroundService.IsStarted = false; + if (BackgroundService.JobServer != null) await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); } @@ -73,6 +81,8 @@ namespace StardewModdingAPI.Web /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { + BackgroundService.IsStarted = false; + BackgroundService.JobServer?.Dispose(); } @@ -83,6 +93,9 @@ namespace StardewModdingAPI.Web [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] public static async Task UpdateWikiAsync() { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods); } @@ -90,6 +103,9 @@ namespace StardewModdingAPI.Web /// <summary>Remove mods which haven't been requested in over 48 hours.</summary> public static Task RemoveStaleModsAsync() { + if (!BackgroundService.IsStarted) + throw new InvalidOperationException($"Must call {nameof(BackgroundService.StartAsync)} before scheduling tasks."); + BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); return Task.CompletedTask; } diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index f2f4c342..522d77cd 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -57,8 +57,8 @@ namespace StardewModdingAPI.Web.Controllers { // choose versions ReleaseVersion[] versions = await this.GetReleaseVersionsAsync(); - ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsForDevs); - ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); + ReleaseVersion? stableVersion = versions.LastOrDefault(version => !version.IsForDevs); + ReleaseVersion? stableVersionForDevs = versions.LastOrDefault(version => version.IsForDevs); // render view IndexVersionModel stableVersionModel = stableVersion != null @@ -89,14 +89,14 @@ namespace StardewModdingAPI.Web.Controllers entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); // get latest stable release - GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + GitRelease? release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); // strip 'noinclude' blocks from release description if (release != null) { - HtmlDocument doc = new HtmlDocument(); + HtmlDocument doc = new(); doc.LoadHtml(release.Body); - foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0]) + foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? Array.Empty<HtmlNode>()) node.Remove(); release.Body = doc.DocumentNode.InnerHtml.Trim(); } @@ -111,7 +111,7 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get a parsed list of SMAPI downloads for a release.</summary> /// <param name="release">The GitHub release.</param> - private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release) + private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease? release) { if (release?.Assets == null) yield break; @@ -122,7 +122,7 @@ namespace StardewModdingAPI.Web.Controllers continue; Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip"); - if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version)) + if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion? version)) continue; bool isForDevs = match.Groups["forDevs"].Success; diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index e06c1236..c551a805 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -64,7 +64,7 @@ namespace StardewModdingAPI.Web.Controllers [Route("json/{schemaName}")] [Route("json/{schemaName}/{id}")] [Route("json/{schemaName}/{id}/{operation}")] - public async Task<ViewResult> Index(string schemaName = null, string id = null, string operation = null) + public async Task<ViewResult> Index(string? schemaName = null, string? id = null, string? operation = null) { // parse arguments schemaName = this.NormalizeSchemaName(schemaName); @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", result); // fetch raw JSON - StoredFileInfo file = await this.Storage.GetAsync(id, renew); + StoredFileInfo file = await this.Storage.GetAsync(id!, renew); if (string.IsNullOrWhiteSpace(file.Content)) return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning); @@ -103,7 +103,7 @@ namespace StardewModdingAPI.Web.Controllers } catch (JsonReaderException ex) { - return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message, ErrorType.None))); + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path!, ex.Message, ErrorType.None))); } // format JSON @@ -119,10 +119,10 @@ namespace StardewModdingAPI.Web.Controllers // load schema JSchema schema; { - FileInfo schemaFile = this.FindSchemaFile(schemaName); + FileInfo? schemaFile = this.FindSchemaFile(schemaName); if (schemaFile == null) return this.View("Index", result.SetParseError($"Invalid schema '{schemaName}'.")); - schema = JSchema.Parse(System.IO.File.ReadAllText(schemaFile.FullName)); + schema = JSchema.Parse(await System.IO.File.ReadAllTextAsync(schemaFile.FullName)); } // get format doc URL @@ -142,7 +142,7 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Save raw JSON data.</summary> [HttpPost, AllowLargePosts] [Route("json")] - public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) + public async Task<ActionResult> PostAsync(JsonValidatorRequestModel? request) { if (request == null) return this.View("Index", this.GetModel(null, null, isEditView: true).SetUploadError("The request seems to be invalid.")); @@ -151,7 +151,7 @@ namespace StardewModdingAPI.Web.Controllers string schemaName = this.NormalizeSchemaName(request.SchemaName); // get raw text - string input = request.Content; + string? input = request.Content; if (string.IsNullOrWhiteSpace(input)) return this.View("Index", this.GetModel(null, schemaName, isEditView: true).SetUploadError("The JSON file seems to be empty.")); @@ -161,7 +161,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", this.GetModel(result.ID, schemaName, isEditView: true).SetContent(input, null).SetUploadError(result.UploadError)); // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID })); + return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName, id = result.ID })!); } @@ -172,14 +172,14 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="pasteID">The stored file ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="isEditView">Whether to show the edit view.</param> - private JsonValidatorModel GetModel(string pasteID, string schemaName, bool isEditView) + private JsonValidatorModel GetModel(string? pasteID, string? schemaName, bool isEditView) { return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats, isEditView); } /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> /// <param name="schemaName">The raw schema name to normalize.</param> - private string NormalizeSchemaName(string schemaName) + private string NormalizeSchemaName(string? schemaName) { schemaName = schemaName?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(schemaName) @@ -189,7 +189,7 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get the schema file given its unique ID.</summary> /// <param name="id">The schema ID.</param> - private FileInfo FindSchemaFile(string id) + private FileInfo? FindSchemaFile(string? id) { // normalize ID id = id?.Trim().ToLower(); @@ -197,7 +197,7 @@ namespace StardewModdingAPI.Web.Controllers return null; // get matching file - DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); + DirectoryInfo schemaDir = new(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) { if (file.Name.Equals($"{id}.json")) @@ -214,13 +214,13 @@ namespace StardewModdingAPI.Web.Controllers // skip through transparent errors if (this.IsTransparentError(error)) { - foreach (var model in error.ChildErrors.SelectMany(this.GetErrorModels)) + foreach (JsonValidatorErrorModel model in error.ChildErrors.SelectMany(this.GetErrorModels)) yield return model; yield break; } // get message - string message = this.GetOverrideError(error); + string? message = this.GetOverrideError(error); if (message == null || message == this.TransparentToken) message = this.FlattenErrorMessage(error); @@ -234,7 +234,7 @@ namespace StardewModdingAPI.Web.Controllers private string FlattenErrorMessage(ValidationError error, int indent = 0) { // get override - string message = this.GetOverrideError(error); + string? message = this.GetOverrideError(error); if (message != null && message != this.TransparentToken) return message; @@ -255,7 +255,7 @@ namespace StardewModdingAPI.Web.Controllers break; case ErrorType.Required: - message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value)}."; + message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value!)}."; break; } @@ -272,7 +272,7 @@ namespace StardewModdingAPI.Web.Controllers if (!error.ChildErrors.Any()) return false; - string @override = this.GetOverrideError(error); + string? @override = this.GetOverrideError(error); return @override == this.TransparentToken || (error.ErrorType == ErrorType.Then && @override == null); @@ -280,18 +280,18 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get an override error from the JSON schema, if any.</summary> /// <param name="error">The schema validation error.</param> - private string GetOverrideError(ValidationError error) + private string? GetOverrideError(ValidationError error) { - string GetRawOverrideError() + string? GetRawOverrideError() { // get override errors - IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(error.Schema, "@errorMessages"); + IDictionary<string, string?>? errors = this.GetExtensionField<Dictionary<string, string?>>(error.Schema, "@errorMessages"); if (errors == null) return null; - errors = new Dictionary<string, string>(errors, StringComparer.OrdinalIgnoreCase); + errors = new Dictionary<string, string?>(errors, StringComparer.OrdinalIgnoreCase); // match error by type and message - foreach ((string target, string errorMessage) in errors) + foreach ((string target, string? errorMessage) in errors) { if (!target.Contains(":")) continue; @@ -302,7 +302,7 @@ namespace StardewModdingAPI.Web.Controllers } // match by type - return errors.TryGetValue(error.ErrorType.ToString(), out string message) + return errors.TryGetValue(error.ErrorType.ToString(), out string? message) ? message?.Trim() : null; } @@ -315,15 +315,12 @@ namespace StardewModdingAPI.Web.Controllers /// <typeparam name="T">The field type.</typeparam> /// <param name="schema">The schema whose extension fields to search.</param> /// <param name="key">The case-insensitive field key.</param> - private T GetExtensionField<T>(JSchema schema, string key) + private T? GetExtensionField<T>(JSchema schema, string key) { - if (schema.ExtensionData != null) + foreach ((string curKey, JToken value) in schema.ExtensionData) { - foreach ((string curKey, JToken value) in schema.ExtensionData) - { - if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) - return value.ToObject<T>(); - } + if (curKey.Equals(key, StringComparison.OrdinalIgnoreCase)) + return value.ToObject<T>(); } return default; @@ -331,7 +328,7 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Format a schema value for display.</summary> /// <param name="value">The value to format.</param> - private string FormatValue(object value) + private string FormatValue(object? value) { return value switch { diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index db53d942..33af5a81 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Controllers [HttpGet] [Route("log")] [Route("log/{id}")] - public async Task<ActionResult> Index(string id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) + public async Task<ActionResult> Index(string? id = null, LogViewFormat format = LogViewFormat.Default, bool renew = false) { // fresh page if (string.IsNullOrWhiteSpace(id)) @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Controllers case LogViewFormat.RawDownload: { - string content = file.Error ?? file.Content; + string content = file.Error ?? file.Content ?? string.Empty; return this.File(Encoding.UTF8.GetBytes(content), "plain/text", $"SMAPI log ({id}).txt"); } @@ -87,7 +87,7 @@ namespace StardewModdingAPI.Web.Controllers public async Task<ActionResult> PostAsync() { // get raw log text - string input = this.Request.Form["input"].FirstOrDefault(); + string? input = this.Request.Form["input"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(input)) return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); @@ -97,7 +97,7 @@ namespace StardewModdingAPI.Web.Controllers return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); // redirect to view - return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })); + return this.Redirect(this.Url.PlainAction("Index", "LogParser", new { id = uploadResult.ID })!); } @@ -109,7 +109,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="expiry">When the uploaded file will no longer be available.</param> /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> /// <param name="uploadError">An error which occurred while uploading the log.</param> - private LogParserModel GetModel(string pasteID, DateTime? expiry = null, string uploadWarning = null, string uploadError = null) + private LogParserModel GetModel(string? pasteID, DateTimeOffset? expiry = null, string? uploadWarning = null, string? uploadError = null) { Platform? platform = this.DetectClientPlatform(); diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 37d763cc..401bba4f 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -76,10 +77,10 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="model">The mod search criteria.</param> /// <param name="version">The requested API version.</param> [HttpPost] - public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version) + public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel? model, [FromRoute] string version) { if (model?.Mods == null) - return new ModEntryModel[0]; + return Array.Empty<ModEntryModel>(); ModUpdateCheckConfig config = this.Config.Value; @@ -92,16 +93,16 @@ namespace StardewModdingAPI.Web.Controllers continue; // special case: if this is an update check for the official SMAPI repo, check the Nexus mod page for beta versions - if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys?.Any(key => key == config.SmapiInfo.DefaultUpdateKey) == true && mod.InstalledVersion?.IsPrerelease() == true) - mod.UpdateKeys = mod.UpdateKeys.Concat(config.SmapiInfo.AddBetaUpdateKeys).ToArray(); + if (mod.ID == config.SmapiInfo.ID && mod.UpdateKeys.Any(key => key == config.SmapiInfo.DefaultUpdateKey) && mod.InstalledVersion?.IsPrerelease() == true) + mod.AddUpdateKeys(config.SmapiInfo.AddBetaUpdateKeys); // fetch result ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion); if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null)) { - var errors = new List<string>(result.Errors); - errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage."); - result.Errors = errors.ToArray(); + result.Errors = result.Errors + .Concat(new[] { $"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage." }) + .ToArray(); } mods[mod.ID] = result; @@ -121,26 +122,26 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> /// <param name="apiVersion">The SMAPI version installed by the player.</param> /// <returns>Returns the mod data if found, else <c>null</c>.</returns> - private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion apiVersion) + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion? apiVersion) { // cross-reference data - ModDataRecord record = this.ModDatabase.Get(search.ID); - WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); + ModDataRecord? record = this.ModDatabase.Get(search.ID); + WikiModEntry? wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.OrdinalIgnoreCase)); UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); - ModOverrideConfig overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID?.Trim(), StringComparison.OrdinalIgnoreCase)); + ModOverrideConfig? overrides = this.Config.Value.ModOverrides.FirstOrDefault(p => p.ID.Equals(search.ID.Trim(), StringComparison.OrdinalIgnoreCase)); bool allowNonStandardVersions = overrides?.AllowNonStandardVersions ?? false; // SMAPI versions with a '-beta' tag indicate major changes that may need beta mod versions. // This doesn't apply to normal prerelease versions which have an '-alpha' tag. - bool isSmapiBeta = apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); + bool isSmapiBeta = apiVersion != null && apiVersion.IsPrerelease() && apiVersion.PrereleaseTag.StartsWith("beta"); // get latest versions - ModEntryModel result = new ModEntryModel { ID = search.ID }; + ModEntryModel result = new(search.ID); IList<string> errors = new List<string>(); - ModEntryVersionModel main = null; - ModEntryVersionModel optional = null; - ModEntryVersionModel unofficial = null; - ModEntryVersionModel unofficialForBeta = null; + ModEntryVersionModel? main = null; + ModEntryVersionModel? optional = null; + ModEntryVersionModel? unofficial = null; + ModEntryVersionModel? unofficialForBeta = null; foreach (UpdateKey updateKey in updateKeys) { // validate update key @@ -160,9 +161,9 @@ namespace StardewModdingAPI.Web.Controllers // handle versions if (this.IsNewer(data.Version, main?.Version)) - main = new ModEntryVersionModel(data.Version, data.Url); + main = new ModEntryVersionModel(data.Version, data.Url!); if (this.IsNewer(data.PreviewVersion, optional?.Version)) - optional = new ModEntryVersionModel(data.PreviewVersion, data.Url); + optional = new ModEntryVersionModel(data.PreviewVersion, data.Url!); } // get unofficial version @@ -170,7 +171,7 @@ namespace StardewModdingAPI.Web.Controllers unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.Url.PlainAction("Index", "Mods", absoluteUrl: true)}#{wikiEntry.Anchor}"); // get unofficial version for beta - if (wikiEntry?.HasBetaInfo == true) + if (wikiEntry is { HasBetaInfo: true }) { if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { @@ -196,13 +197,13 @@ namespace StardewModdingAPI.Web.Controllers if (overrides?.SetUrl != null) { if (main != null) - main.Url = overrides.SetUrl; + main = new(main.Version, overrides.SetUrl); if (optional != null) - optional.Url = overrides.SetUrl; + optional = new(optional.Version, overrides.SetUrl); } // get recommended update (if any) - ISemanticVersion installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); + ISemanticVersion? installedVersion = this.ModSites.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.Overrides?.ChangeLocalVersions, allowNonStandard: allowNonStandardVersions); if (apiVersion != null && installedVersion != null) { // get newer versions @@ -217,7 +218,7 @@ namespace StardewModdingAPI.Web.Controllers updates.Add(unofficialForBeta); // get newest version - ModEntryVersionModel newest = null; + ModEntryVersionModel? newest = null; foreach (ModEntryVersionModel update in updates) { if (newest == null || update.Version.IsNewerThan(newest.Version)) @@ -243,7 +244,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="currentVersion">The current semantic version.</param> /// <param name="newVersion">The target semantic version.</param> /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered prerelease updates.</param> - private bool IsRecommendedUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + private bool IsRecommendedUpdate(ISemanticVersion currentVersion, [NotNullWhen(true)] ISemanticVersion? newVersion, bool useBetaChannel) { return newVersion != null @@ -254,7 +255,7 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary> /// <param name="current">The current version.</param> /// <param name="other">The other version.</param> - private bool IsNewer(ISemanticVersion current, ISemanticVersion other) + private bool IsNewer([NotNullWhen(true)] ISemanticVersion? current, ISemanticVersion? other) { return current != null && (other == null || other.IsOlderThan(current)); } @@ -263,17 +264,20 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="updateKey">The namespaced update key.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { + if (!updateKey.LooksValid) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + // get mod page IModPage page; { bool isCached = - this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached<IModPage> cachedMod) + this.ModCache.TryGetMod(updateKey.Site, updateKey.ID, out Cached<IModPage>? cachedMod) && !this.ModCache.IsStale(cachedMod.LastUpdated, cachedMod.Data.Status == RemoteModStatus.TemporaryError ? this.Config.Value.ErrorCacheMinutes : this.Config.Value.SuccessCacheMinutes); if (isCached) - page = cachedMod.Data; + page = cachedMod!.Data; else { page = await this.ModSites.GetModPageAsync(updateKey); @@ -289,7 +293,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="specifiedKeys">The specified update keys.</param> /// <param name="record">The mod's entry in SMAPI's internal database.</param> /// <param name="entry">The mod's entry in the wiki list.</param> - private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable<UpdateKey> GetUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // get unique update keys List<UpdateKey> updateKeys = this.GetUnfilteredUpdateKeys(specifiedKeys, record, entry) @@ -308,7 +312,7 @@ namespace StardewModdingAPI.Web.Controllers // if the list has both an update key (like "Nexus:2400") and subkey (like "Nexus:2400@subkey") for the same page, the subkey takes priority { var removeKeys = new HashSet<UpdateKey>(); - foreach (var key in updateKeys) + foreach (UpdateKey key in updateKeys) { if (key.Subkey != null) removeKeys.Add(new UpdateKey(key.Site, key.ID, null)); @@ -324,7 +328,7 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="specifiedKeys">The specified update keys.</param> /// <param name="record">The mod's entry in SMAPI's internal database.</param> /// <param name="entry">The mod's entry in the wiki list.</param> - private IEnumerable<string> GetUnfilteredUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable<string> GetUnfilteredUpdateKeys(string[]? specifiedKeys, ModDataRecord? record, WikiModEntry? entry) { // specified update keys foreach (string key in specifiedKeys ?? Array.Empty<string>()) @@ -335,7 +339,7 @@ namespace StardewModdingAPI.Web.Controllers // default update key { - string defaultKey = record?.GetDefaultUpdateKey(); + string? defaultKey = record?.GetDefaultUpdateKey(); if (!string.IsNullOrWhiteSpace(defaultKey)) yield return defaultKey; } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index c62ed605..919afa5b 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Mvc; @@ -52,8 +53,8 @@ namespace StardewModdingAPI.Web.Controllers public ModListModel FetchData() { // fetch cached data - if (!this.Cache.TryGetWikiMetadata(out Cached<WikiMetadata> metadata)) - return new ModListModel(); + if (!this.Cache.TryGetWikiMetadata(out Cached<WikiMetadata>? metadata)) + return new ModListModel(null, null, Array.Empty<ModModel>(), lastUpdated: DateTimeOffset.UtcNow, isStale: true); // build model return new ModListModel( diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index 864aa215..bd414ea2 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework public void OnAuthorization(AuthorizationFilterContext context) { IFeatureCollection features = context.HttpContext.Features; - IFormFeature formFeature = features.Get<IFormFeature>(); + IFormFeature? formFeature = features.Get<IFormFeature>(); if (formFeature?.Form == null) { diff --git a/src/SMAPI.Web/Framework/Caching/Cached.cs b/src/SMAPI.Web/Framework/Caching/Cached.cs index 52041a16..b393e1e1 100644 --- a/src/SMAPI.Web/Framework/Caching/Cached.cs +++ b/src/SMAPI.Web/Framework/Caching/Cached.cs @@ -10,21 +10,18 @@ namespace StardewModdingAPI.Web.Framework.Caching ** Accessors *********/ /// <summary>The cached data.</summary> - public T Data { get; set; } + public T Data { get; } /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } + public DateTimeOffset LastUpdated { get; } /// <summary>When the data was last requested through the mod API.</summary> - public DateTimeOffset LastRequested { get; set; } + public DateTimeOffset LastRequested { get; internal set; } /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public Cached() { } - /// <summary>Construct an instance.</summary> /// <param name="data">The cached data.</param> public Cached(T data) diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs index 0d912c7b..fb74e9da 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -1,6 +1,6 @@ using System; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The fetched mod.</param> /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true); + bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached<IModPage>? mod, bool markRequested = true); /// <summary>Save data fetched for a mod.</summary> /// <param name="site">The mod site on which the mod is found.</param> diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs index 9769793c..4ba0bd20 100644 --- a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheMemoryRepository.cs @@ -1,8 +1,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; -using StardewModdingAPI.Web.Framework.Clients; namespace StardewModdingAPI.Web.Framework.Caching.Mods { @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Mods /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> /// <param name="mod">The fetched mod.</param> /// <param name="markRequested">Whether to update the mod's 'last requested' date.</param> - public bool TryGetMod(ModSiteKey site, string id, out Cached<IModPage> mod, bool markRequested = true) + public bool TryGetMod(ModSiteKey site, string id, [NotNullWhen(true)] out Cached<IModPage>? mod, bool markRequested = true) { // get mod if (!this.Mods.TryGetValue(this.GetKey(site, id), out var cachedMod)) diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs index 2ab7ea5a..b8a0df34 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.Framework.Caching.Wiki @@ -12,16 +13,16 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> - bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata); + bool TryGetWikiMetadata([NotNullWhen(true)] out Cached<WikiMetadata>? metadata); /// <summary>Get the cached wiki mods.</summary> /// <param name="filter">A filter to apply, if any.</param> - IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null); + IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool>? filter = null); /// <summary>Save data fetched from the wiki compatibility list.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="mods">The mod data.</param> - void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods); + void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable<WikiModEntry> mods); } } diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs index 064a7c3c..8b4338e2 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheMemoryRepository.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -12,10 +13,10 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Fields *********/ /// <summary>The saved wiki metadata.</summary> - private Cached<WikiMetadata> Metadata; + private Cached<WikiMetadata>? Metadata; /// <summary>The cached wiki data.</summary> - private Cached<WikiModEntry>[] Mods = new Cached<WikiModEntry>[0]; + private Cached<WikiModEntry>[] Mods = Array.Empty<Cached<WikiModEntry>>(); /********* @@ -23,7 +24,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki *********/ /// <summary>Get the cached wiki metadata.</summary> /// <param name="metadata">The fetched metadata.</param> - public bool TryGetWikiMetadata(out Cached<WikiMetadata> metadata) + public bool TryGetWikiMetadata([NotNullWhen(true)] out Cached<WikiMetadata>? metadata) { metadata = this.Metadata; return metadata != null; @@ -31,7 +32,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// <summary>Get the cached wiki mods.</summary> /// <param name="filter">A filter to apply, if any.</param> - public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool> filter = null) + public IEnumerable<Cached<WikiModEntry>> GetWikiMods(Func<WikiModEntry, bool>? filter = null) { foreach (var mod in this.Mods) { @@ -44,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> /// <param name="mods">The mod data.</param> - public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods) + public void SaveWikiData(string? stableVersion, string? betaVersion, IEnumerable<WikiModEntry> mods) { this.Metadata = new Cached<WikiMetadata>(new WikiMetadata(stableVersion, betaVersion)); this.Mods = mods.Select(mod => new Cached<WikiModEntry>(mod)).ToArray(); diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs index c04de4a5..f53ea201 100644 --- a/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiMetadata.cs @@ -7,22 +7,19 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki ** Accessors *********/ /// <summary>The current stable Stardew Valley version.</summary> - public string StableVersion { get; set; } + public string? StableVersion { get; } /// <summary>The current beta Stardew Valley version.</summary> - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public WikiMetadata() { } - - /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The current stable Stardew Valley version.</param> /// <param name="betaVersion">The current beta Stardew Valley version.</param> - public WikiMetadata(string stableVersion, string betaVersion) + public WikiMetadata(string? stableVersion, string? betaVersion) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index b8b05878..ce0f1122 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -51,14 +51,14 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch HTML - string html; + string? html; try { html = await this.Client .GetAsync(string.Format(this.ModPageUrlFormat, parsedId)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) + catch (ApiException ex) when (ex.Status is HttpStatusCode.NotFound or HttpStatusCode.Forbidden) { return page.SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } @@ -67,7 +67,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish // extract mod info string url = this.GetModUrl(parsedId); - string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; + string? version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText; string name = doc.DocumentNode.SelectSingleNode("//h1").ChildNodes[0].InnerText.Trim(); if (name.StartsWith("[SMAPI]")) name = name.Substring("[SMAPI]".Length).TrimStart(); @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -90,7 +90,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish /// <param name="id">The mod ID.</param> private string GetModUrl(uint id) { - UriBuilder builder = new UriBuilder(this.Client.BaseClient.BaseAddress); + UriBuilder builder = new(this.Client.BaseClient.BaseAddress!); builder.Path += string.Format(this.ModPageUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs index d8008721..d351b42d 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge private readonly IClient Client; /// <summary>A regex pattern which matches a version number in a CurseForge mod file name.</summary> - private readonly Regex VersionInNamePattern = new Regex(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); + private readonly Regex VersionInNamePattern = new(@"^(?:.+? | *)v?(\d+\.\d+(?:\.\d+)?(?:-.+?)?) *(?:\.(?:zip|rar|7z))?$", RegexOptions.Compiled); /********* @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -49,9 +49,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); // get raw data - ModModel mod = await this.Client + ModModel? mod = await this.Client .GetAsync($"addon/{parsedId}") - .As<ModModel>(); + .As<ModModel?>(); if (mod == null) return page.SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); @@ -71,7 +71,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } @@ -80,9 +80,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge *********/ /// <summary>Get a raw version string for a mod file, if available.</summary> /// <param name="file">The file whose version to get.</param> - private string GetRawVersion(ModFileModel file) + private string? GetRawVersion(ModFileModel file) { - Match match = this.VersionInNamePattern.Match(file.DisplayName); + Match match = this.VersionInNamePattern.Match(file.DisplayName ?? ""); if (!match.Success) match = this.VersionInNamePattern.Match(file.FileName); diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs index 9de74847..e9adcf20 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -3,10 +3,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels /// <summary>Metadata from the CurseForge API about a mod file.</summary> public class ModFileModel { + /********* + ** Accessors + *********/ /// <summary>The file name as downloaded.</summary> - public string FileName { get; set; } + public string FileName { get; } /// <summary>The file display name.</summary> - public string DisplayName { get; set; } + public string? DisplayName { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fileName">The file name as downloaded.</param> + /// <param name="displayName">The file display name.</param> + public ModFileModel(string fileName, string? displayName) + { + this.FileName = fileName; + this.DisplayName = displayName; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs index 48cd185b..fd7796f2 100644 --- a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -3,16 +3,36 @@ namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels /// <summary>An mod from the CurseForge API.</summary> public class ModModel { + /********* + ** Accessors + *********/ /// <summary>The mod's unique ID on CurseForge.</summary> - public int ID { get; set; } + public int ID { get; } /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The web URL for the mod page.</summary> - public string WebsiteUrl { get; set; } + public string WebsiteUrl { get; } /// <summary>The available file downloads.</summary> - public ModFileModel[] LatestFiles { get; set; } + public ModFileModel[] LatestFiles { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID on CurseForge.</param> + /// <param name="name">The mod name.</param> + /// <param name="websiteUrl">The web URL for the mod page.</param> + /// <param name="latestFiles">The available file downloads.</param> + public ModModel(int id, string name, string websiteUrl, ModFileModel[] latestFiles) + { + this.ID = id; + this.Name = name; + this.WebsiteUrl = websiteUrl; + this.LatestFiles = latestFiles; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs index f08b471c..548f17c3 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModDownload.cs @@ -7,26 +7,23 @@ namespace StardewModdingAPI.Web.Framework.Clients ** Accessors *********/ /// <summary>The download's display name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The download's description.</summary> - public string Description { get; set; } + public string? Description { get; } /// <summary>The download's file version.</summary> - public string Version { get; set; } + public string? Version { get; } /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public GenericModDownload() { } - /// <summary>Construct an instance.</summary> /// <param name="name">The download's display name.</param> /// <param name="description">The download's description.</param> /// <param name="version">The download's file version.</param> - public GenericModDownload(string name, string description, string version) + public GenericModDownload(string name, string? description, string? version) { this.Name = name; this.Description = description; diff --git a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs index 622e6c56..5353c7e1 100644 --- a/src/SMAPI.Web/Framework/Clients/GenericModPage.cs +++ b/src/SMAPI.Web/Framework/Clients/GenericModPage.cs @@ -1,4 +1,6 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -17,30 +19,31 @@ namespace StardewModdingAPI.Web.Framework.Clients public string Id { get; set; } /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; set; } /// <summary>The mod's semantic version number.</summary> - public string Version { get; set; } + public string? Version { get; set; } /// <summary>The mod's web URL.</summary> - public string Url { get; set; } + public string? Url { get; set; } /// <summary>The mod downloads.</summary> - public IModDownload[] Downloads { get; set; } = new IModDownload[0]; + public IModDownload[] Downloads { get; set; } = Array.Empty<IModDownload>(); /// <summary>The mod availability status on the remote site.</summary> - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; set; } = RemoteModStatus.InvalidData; /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } + public string? Error { get; set; } + + /// <summary>Whether the mod data is valid.</summary> + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + public bool IsValid => this.Status == RemoteModStatus.Ok; /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public GenericModPage() { } - /// <summary>Construct an instance.</summary> /// <param name="site">The mod site containing the mod.</param> /// <param name="id">The mod's unique ID within the site.</param> @@ -55,12 +58,13 @@ namespace StardewModdingAPI.Web.Framework.Clients /// <param name="version">The mod's semantic version number.</param> /// <param name="url">The mod's web URL.</param> /// <param name="downloads">The mod downloads.</param> - public IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads) + public IModPage SetInfo(string name, string? version, string url, IEnumerable<IModDownload> downloads) { this.Name = name; this.Version = version; this.Url = url; this.Downloads = downloads.ToArray(); + this.Status = RemoteModStatus.Ok; return this; } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs index 73ce4025..dbce9368 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitAsset.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>A GitHub download attached to a release.</summary> internal class GitAsset { + /********* + ** Accessors + *********/ /// <summary>The file name.</summary> [JsonProperty("name")] - public string FileName { get; set; } + public string FileName { get; } /// <summary>The file content type.</summary> [JsonProperty("content_type")] - public string ContentType { get; set; } + public string ContentType { get; } /// <summary>The download URL.</summary> [JsonProperty("browser_download_url")] - public string DownloadUrl { get; set; } + public string DownloadUrl { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fileName">The file name.</param> + /// <param name="contentType">The file content type.</param> + /// <param name="downloadUrl">The download URL.</param> + public GitAsset(string fileName, string contentType, string downloadUrl) + { + this.FileName = fileName; + this.ContentType = contentType; + this.DownloadUrl = downloadUrl; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 671f077c..785979a5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -33,26 +33,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <param name="acceptHeader">The Accept header value expected by the GitHub API.</param> /// <param name="username">The username with which to authenticate to the GitHub API.</param> /// <param name="password">The password with which to authenticate to the GitHub API.</param> - public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string? username, string? password) { this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); if (!string.IsNullOrWhiteSpace(username)) - this.Client = this.Client.SetBasicAuthentication(username, password); + this.Client = this.Client.SetBasicAuthentication(username, password!); } /// <summary>Get basic metadata for a GitHub repository, if available.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> - public async Task<GitRepo> GetRepositoryAsync(string repo) + public async Task<GitRepo?> GetRepositoryAsync(string repo) { this.AssertKeyFormat(repo); try { return await this.Client .GetAsync($"repos/{repo}") - .As<GitRepo>(); + .As<GitRepo?>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -64,7 +64,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> /// <returns>Returns the release if found, else <c>null</c>.</returns> - public async Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false) + public async Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false) { this.AssertKeyFormat(repo); try @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub return await this.Client .GetAsync($"repos/{repo}/releases/latest") - .As<GitRelease>(); + .As<GitRelease?>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { @@ -89,7 +89,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -97,15 +97,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/SMAPI'."); // fetch repo info - GitRepo repository = await this.GetRepositoryAsync(id); + GitRepo? repository = await this.GetRepositoryAsync(id); if (repository == null) return page.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); string name = repository.FullName; string url = $"{repository.WebUrl}/releases"; // get releases - GitRelease latest; - GitRelease preview; + GitRelease? latest; + GitRelease? preview; { // get latest release (whether preview or stable) latest = await this.GetLatestReleaseAsync(id, includePrerelease: true); @@ -116,7 +116,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub preview = null; if (latest.IsPrerelease) { - GitRelease release = await this.GetLatestReleaseAsync(id, includePrerelease: false); + GitRelease? release = await this.GetLatestReleaseAsync(id, includePrerelease: false); if (release != null) { preview = latest; @@ -127,8 +127,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub // get downloads IModDownload[] downloads = new[] { latest, preview } - .Where(release => release != null) - .Select(release => (IModDownload)new GenericModDownload(release.Name, release.Body, release.Tag)) + .Where(release => release is not null) + .Select(release => (IModDownload)new GenericModDownload(release!.Name, release.Body, release.Tag)) .ToArray(); // return info @@ -138,7 +138,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs index 736efbe6..24d6c3c5 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>The license info for a GitHub project.</summary> internal class GitLicense { + /********* + ** Accessors + *********/ /// <summary>The license display name.</summary> [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// <summary>The SPDX ID for the license.</summary> [JsonProperty("spdx_id")] - public string SpdxId { get; set; } + public string SpdxId { get; } /// <summary>The URL for the license info.</summary> [JsonProperty("url")] - public string Url { get; set; } + public string Url { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The license display name.</param> + /// <param name="spdxId">The SPDX ID for the license.</param> + /// <param name="url">The URL for the license info.</param> + public GitLicense(string name, string spdxId, string url) + { + this.Name = name; + this.SpdxId = spdxId; + this.Url = url; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index d0db5297..9de6f020 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -1,3 +1,4 @@ +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.GitHub @@ -10,24 +11,45 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>The display name.</summary> [JsonProperty("name")] - public string Name { get; set; } + public string Name { get; } /// <summary>The semantic version string.</summary> [JsonProperty("tag_name")] - public string Tag { get; set; } + public string Tag { get; } /// <summary>The Markdown description for the release.</summary> - public string Body { get; set; } + public string Body { get; internal set; } /// <summary>Whether this is a draft version.</summary> [JsonProperty("draft")] - public bool IsDraft { get; set; } + public bool IsDraft { get; } /// <summary>Whether this is a prerelease version.</summary> [JsonProperty("prerelease")] - public bool IsPrerelease { get; set; } + public bool IsPrerelease { get; } /// <summary>The attached files.</summary> - public GitAsset[] Assets { get; set; } + public GitAsset[] Assets { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The display name.</param> + /// <param name="tag">The semantic version string.</param> + /// <param name="body">The Markdown description for the release.</param> + /// <param name="isDraft">Whether this is a draft version.</param> + /// <param name="isPrerelease">Whether this is a prerelease version.</param> + /// <param name="assets">The attached files.</param> + public GitRelease(string name, string tag, string? body, bool isDraft, bool isPrerelease, GitAsset[]? assets) + { + this.Name = name; + this.Tag = tag; + this.Body = body ?? string.Empty; + this.IsDraft = isDraft; + this.IsPrerelease = isPrerelease; + this.Assets = assets ?? Array.Empty<GitAsset>(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs index 7d80576e..879b5e49 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -5,16 +5,34 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Basic metadata about a GitHub project.</summary> internal class GitRepo { + /********* + ** Accessors + *********/ /// <summary>The full repository name, including the owner.</summary> [JsonProperty("full_name")] - public string FullName { get; set; } + public string FullName { get; } /// <summary>The URL to the repository web page, if any.</summary> [JsonProperty("html_url")] - public string WebUrl { get; set; } + public string? WebUrl { get; } /// <summary>The code license, if any.</summary> [JsonProperty("license")] - public GitLicense License { get; set; } + public GitLicense? License { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="fullName">The full repository name, including the owner.</param> + /// <param name="webUrl">The URL to the repository web page, if any.</param> + /// <param name="license">The code license, if any.</param> + public GitRepo(string fullName, string? webUrl, GitLicense? license) + { + this.FullName = fullName; + this.WebUrl = webUrl; + this.License = license; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 0d6f4643..886e32d3 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -12,12 +12,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Get basic metadata for a GitHub repository, if available.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <returns>Returns the repository info if it exists, else <c>null</c>.</returns> - Task<GitRepo> GetRepositoryAsync(string repo); + Task<GitRepo?> GetRepositoryAsync(string repo); /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> /// <returns>Returns the release if found, else <c>null</c>.</returns> - Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false); + Task<GitRelease?> GetLatestReleaseAsync(string repo, bool includePrerelease = false); } } diff --git a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs index 33277711..3697ffae 100644 --- a/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs +++ b/src/SMAPI.Web/Framework/Clients/IModSiteClient.cs @@ -18,6 +18,6 @@ namespace StardewModdingAPI.Web.Framework.Clients *********/ /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - Task<IModPage> GetModData(string id); + Task<IModPage?> GetModData(string id); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs index 3a1c5b9d..c60b2c90 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropClient.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Pathoschild.Http.Client; using StardewModdingAPI.Toolkit.Framework.UpdateData; @@ -41,9 +42,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this method.")] + public async Task<IModPage?> GetModData(string id) { - var page = new GenericModPage(this.SiteKey, id); + IModPage page = new GenericModPage(this.SiteKey, id); if (!long.TryParse(id, out long parsedId)) return page.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); @@ -58,9 +60,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop Mods = true }) .As<ModListModel>(); - ModModel mod = response.Mods[parsedId]; - if (mod.Mod?.Title == null || mod.Mod.ErrorCode.HasValue) - return null; + + if (!response.Mods.TryGetValue(parsedId, out ModModel? mod) || mod?.Mod is null) + return page.SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop page with this ID."); + if (mod.Mod.ErrorCode is not null) + return page.SetError(RemoteModStatus.InvalidData, $"ModDrop returned error code {mod.Mod.ErrorCode} for mod ID '{id}'."); // get files var downloads = new List<IModDownload>(); @@ -75,7 +79,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop } // return info - string name = mod.Mod?.Title; + string name = mod.Mod.Title; string url = string.Format(this.ModUrlFormat, id); return page.SetInfo(name: name, version: null, url: url, downloads: downloads); } @@ -83,7 +87,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.Client?.Dispose(); + this.Client.Dispose(); } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs index b01196f4..31905338 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/FileDataModel.cs @@ -5,27 +5,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Metadata from the ModDrop API about a mod file.</summary> public class FileDataModel { + /********* + ** Accessors + *********/ /// <summary>The file title.</summary> [JsonProperty("title")] - public string Name { get; set; } + public string Name { get; } /// <summary>The file description.</summary> [JsonProperty("desc")] - public string Description { get; set; } + public string Description { get; } /// <summary>The file version.</summary> - public string Version { get; set; } + public string Version { get; } /// <summary>Whether the file is deleted.</summary> - public bool IsDeleted { get; set; } + public bool IsDeleted { get; } /// <summary>Whether the file is hidden from users.</summary> - public bool IsHidden { get; set; } + public bool IsHidden { get; } /// <summary>Whether this is the default file for the mod.</summary> - public bool IsDefault { get; set; } + public bool IsDefault { get; } /// <summary>Whether this is an archived file.</summary> - public bool IsOld { get; set; } + public bool IsOld { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The file title.</param> + /// <param name="description">The file description.</param> + /// <param name="version">The file version.</param> + /// <param name="isDeleted">Whether the file is deleted.</param> + /// <param name="isHidden">Whether the file is hidden from users.</param> + /// <param name="isDefault">Whether this is the default file for the mod.</param> + /// <param name="isOld">Whether this is an archived file.</param> + public FileDataModel(string name, string description, string version, bool isDeleted, bool isHidden, bool isDefault, bool isOld) + { + this.Name = name; + this.Description = description; + this.Version = version; + this.IsDeleted = isDeleted; + this.IsHidden = isHidden; + this.IsDefault = isDefault; + this.IsOld = isOld; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs index cfdd6a4e..0654b576 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModDataModel.cs @@ -3,13 +3,31 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>Metadata about a mod from the ModDrop API.</summary> public class ModDataModel { + /********* + ** Accessors + *********/ /// <summary>The mod's unique ID on ModDrop.</summary> public int ID { get; set; } + /// <summary>The mod name.</summary> + public string Title { get; set; } + /// <summary>The error code, if any.</summary> public int? ErrorCode { get; set; } - /// <summary>The mod name.</summary> - public string Title { get; set; } + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The mod's unique ID on ModDrop.</param> + /// <param name="title">The mod name.</param> + /// <param name="errorCode">The error code, if any.</param> + public ModDataModel(int id, string title, int? errorCode) + { + this.ID = id; + this.Title = title; + this.ErrorCode = errorCode; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs index 7f692ca1..cb4be35c 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModListModel.cs @@ -5,7 +5,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>A list of mods from the ModDrop API.</summary> public class ModListModel { + /********* + ** Accessors + *********/ /// <summary>The mod data.</summary> - public IDictionary<long, ModModel> Mods { get; set; } + public IDictionary<long, ModModel> Mods { get; } = new Dictionary<long, ModModel>(); } } diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs index 9f4b2c6f..60b818d6 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ResponseModels/ModModel.cs @@ -3,10 +3,26 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop.ResponseModels /// <summary>An entry in a mod list from the ModDrop API.</summary> public class ModModel { + /********* + ** Accessors + *********/ /// <summary>The available file downloads.</summary> - public FileDataModel[] Files { get; set; } + public FileDataModel[] Files { get; } /// <summary>The mod metadata.</summary> - public ModDataModel Mod { get; set; } + public ModDataModel Mod { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="files">The available file downloads.</param> + /// <param name="mod">The mod metadata.</param> + public ModModel(FileDataModel[] files, ModDataModel mod) + { + this.Files = files; + this.Mod = mod; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs new file mode 100644 index 00000000..6edd5f64 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/DisabledNexusClient.cs @@ -0,0 +1,31 @@ +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>A client for the Nexus website which does nothing, used for local development.</summary> + internal class DisabledNexusClient : INexusClient + { + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public ModSiteKey SiteKey => ModSiteKey.Nexus; + + + /********* + ** Public methods + *********/ + /// <summary>Get update check info about a mod.</summary> + /// <param name="id">The mod ID.</param> + public Task<IModPage?> GetModData(string id) + { + return Task.FromResult<IModPage?>( + new GenericModPage(ModSiteKey.Nexus, id).SetError(RemoteModStatus.TemporaryError, "The Nexus client is currently disabled due to the configuration.") + ); + } + + /// <inheritdoc /> + public void Dispose() { } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs index 4ba94f81..23b25f95 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>Get update check info about a mod.</summary> /// <param name="id">The mod ID.</param> - public async Task<IModPage> GetModData(string id) + public async Task<IModPage?> GetModData(string id) { IModPage page = new GenericModPage(this.SiteKey, id); @@ -70,25 +70,25 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus // adult content are hidden for anonymous users, so fall back to the API in that case. // Note that the API has very restrictive rate limits which means we can't just use it // for all cases. - NexusMod mod = await this.GetModFromWebsiteAsync(parsedId); + NexusMod? mod = await this.GetModFromWebsiteAsync(parsedId); if (mod?.Status == NexusModStatus.AdultContentForbidden) mod = await this.GetModFromApiAsync(parsedId); // page doesn't exist - if (mod == null || mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished) + if (mod == null || mod.Status is NexusModStatus.Hidden or NexusModStatus.NotPublished) return page.SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); // return info - page.SetInfo(name: mod.Name, url: mod.Url, version: mod.Version, downloads: mod.Downloads); + page.SetInfo(name: mod.Name ?? parsedId.ToString(), url: mod.Url ?? this.GetModUrl(parsedId), version: mod.Version, downloads: mod.Downloads); if (mod.Status != NexusModStatus.Ok) - page.SetError(RemoteModStatus.TemporaryError, mod.Error); + page.SetError(RemoteModStatus.TemporaryError, mod.Error!); return page; } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> public void Dispose() { - this.WebClient?.Dispose(); + this.WebClient.Dispose(); } @@ -98,7 +98,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus /// <summary>Get metadata about a mod by scraping the Nexus website.</summary> /// <param name="id">The Nexus mod ID.</param> /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - private async Task<NexusMod> GetModFromWebsiteAsync(uint id) + private async Task<NexusMod?> GetModFromWebsiteAsync(uint id) { // fetch HTML string html; @@ -114,35 +114,38 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // parse HTML - var doc = new HtmlDocument(); + HtmlDocument doc = new(); doc.LoadHtml(html); // handle Nexus error message - HtmlNode node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); + HtmlNode? node = doc.DocumentNode.SelectSingleNode("//div[contains(@class, 'site-notice')][contains(@class, 'warning')]"); if (node != null) { string[] errorParts = node.InnerText.Trim().Split(new[] { '\n' }, 2, System.StringSplitOptions.RemoveEmptyEntries); string errorCode = errorParts[0]; - string errorText = errorParts.Length > 1 ? errorParts[1] : null; + string? errorText = errorParts.Length > 1 ? errorParts[1] : null; switch (errorCode.Trim().ToLower()) { case "not found": return null; default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; + return new NexusMod( + status: this.GetWebStatus(errorCode), + error: $"Nexus error: {errorCode} ({errorText})." + ); } } // extract mod info string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); - string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); - SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion); + string? name = doc.DocumentNode.SelectSingleNode("//div[@id='pagetitle']//h1")?.InnerText.Trim(); + string? version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim(); + SemanticVersion.TryParse(version, out ISemanticVersion? parsedVersion); // extract files var downloads = new List<IModDownload>(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + foreach (HtmlNode fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) { string sectionName = fileSection.Descendants("h2").First().InnerText; if (sectionName != "Main files" && sectionName != "Optional files") @@ -152,7 +155,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus { string fileName = container.GetDataAttribute("name").Value; string fileVersion = container.GetDataAttribute("version").Value; - string description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 + string? description = container.SelectSingleNode("following-sibling::*[1][self::dd]//div").InnerText?.Trim(); // get text of next <dd> tag; derived from https://stackoverflow.com/a/25535623/262123 downloads.Add( new GenericModDownload(fileName, description, fileVersion) @@ -161,13 +164,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus } // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - Url = url, - Downloads = downloads.ToArray() - }; + return new NexusMod( + name: name ?? id.ToString(), + version: parsedVersion?.ToString() ?? version, + url: url, + downloads: downloads.ToArray() + ); } /// <summary>Get metadata about a mod from the Nexus API.</summary> @@ -180,22 +182,21 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); // yield info - return new NexusMod - { - Name = mod.Name, - Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, - Url = this.GetModUrl(id), - Downloads = files.Files + return new NexusMod( + name: mod.Name, + version: SemanticVersion.TryParse(mod.Version, out ISemanticVersion? version) ? version.ToString() : mod.Version, + url: this.GetModUrl(id), + downloads: files.Files .Select(file => (IModDownload)new GenericModDownload(file.Name, file.Description, file.FileVersion)) .ToArray() - }; + ); } /// <summary>Get the full mod page URL for a given ID.</summary> /// <param name="id">The mod ID.</param> private string GetModUrl(uint id) { - UriBuilder builder = new UriBuilder(this.WebClient.BaseClient.BaseAddress); + UriBuilder builder = new(this.WebClient.BaseClient.BaseAddress!); builder.Path += string.Format(this.WebModUrlFormat, id); return builder.Uri.ToString(); } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs index aef90ede..3155cfda 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/ResponseModels/NexusMod.cs @@ -1,3 +1,4 @@ +using System; using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels @@ -9,25 +10,53 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus.ResponseModels ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; } /// <summary>The mod's semantic version number.</summary> - public string Version { get; set; } + public string? Version { get; } /// <summary>The mod's web URL.</summary> [JsonProperty("mod_page_uri")] - public string Url { get; set; } + public string? Url { get; } /// <summary>The mod's publication status.</summary> [JsonIgnore] - public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + public NexusModStatus Status { get; } /// <summary>The files available to download.</summary> [JsonIgnore] - public IModDownload[] Downloads { get; set; } + public IModDownload[] Downloads { get; } /// <summary>A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name</param> + /// <param name="version">The mod's semantic version number.</param> + /// <param name="url">The mod's web URL.</param> + /// <param name="downloads">The files available to download.</param> + public NexusMod(string name, string? version, string url, IModDownload[] downloads) + { + this.Name = name; + this.Version = version; + this.Url = url; + this.Status = NexusModStatus.Ok; + this.Downloads = downloads; + } + + /// <summary>Construct an instance.</summary> + /// <param name="status">The mod's publication status.</param> + /// <param name="error">A custom user-friendly error which indicates why fetching the mod info failed (if applicable).</param> + public NexusMod(NexusModStatus status, string error) + { + this.Status = status; + this.Error = error; + this.Downloads = Array.Empty<IModDownload>(); + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index 813ea115..7f40e713 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -1,15 +1,35 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { /// <summary>The response for a get-paste request.</summary> internal class PasteInfo { + /********* + ** Accessors + *********/ /// <summary>Whether the log was successfully fetched.</summary> - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(PasteInfo.Content))] + [MemberNotNullWhen(false, nameof(PasteInfo.Error))] + public bool Success => this.Error == null || this.Content != null; /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> - public string Content { get; set; } + public string? Content { get; internal set; } + + /// <summary>The error message (if <see cref="Success"/> is <c>false</c>).</summary> + public string? Error { get; } + - /// <summary>The error message if saving failed.</summary> - public string Error { get; set; } + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="content">The fetched paste content.</param> + /// <param name="error">The error message, if it failed.</param> + public PasteInfo(string? content, string? error) + { + this.Content = content; + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 1be00be7..0e00f071 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -33,24 +33,24 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin try { // get from API - string content = await this.Client + string? content = await this.Client .GetAsync($"raw/{id}") .AsString(); // handle Pastebin errors if (string.IsNullOrWhiteSpace(content)) - return new PasteInfo { Error = "Received an empty response from Pastebin." }; + return new PasteInfo(null, "Received an empty response from Pastebin."); if (content.StartsWith("<!DOCTYPE")) - return new PasteInfo { Error = $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it." }; - return new PasteInfo { Success = true, Content = content }; + return new PasteInfo(null, $"Received a captcha challenge from Pastebin. Please visit https://pastebin.com/{id} in a new window to solve it."); + return new PasteInfo(content, null); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) { - return new PasteInfo { Error = "There's no log with that ID." }; + return new PasteInfo(null, "There's no log with that ID."); } catch (Exception ex) { - return new PasteInfo { Error = $"Pastebin error: {ex}" }; + return new PasteInfo(null, $"Pastebin error: {ex}"); } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs index 676d660d..e7a2df13 100644 --- a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Compression; using System.Text; @@ -29,9 +30,9 @@ namespace StardewModdingAPI.Web.Framework.Compression // compressed byte[] compressedData; - using (MemoryStream stream = new MemoryStream()) + using (MemoryStream stream = new()) { - using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + using (GZipStream zipStream = new(stream, CompressionLevel.Optimal, leaveOpen: true)) zipStream.Write(buffer, 0, buffer.Length); stream.Position = 0; @@ -51,8 +52,12 @@ namespace StardewModdingAPI.Web.Framework.Compression /// <summary>Decompress a string.</summary> /// <param name="rawText">The compressed text.</param> /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - public string DecompressString(string rawText) + [return: NotNullIfNotNull("rawText")] + public string? DecompressString(string? rawText) { + if (rawText is null) + return rawText; + // get raw bytes byte[] zipBuffer; try @@ -69,7 +74,7 @@ namespace StardewModdingAPI.Web.Framework.Compression return rawText; // decompress - using MemoryStream memoryStream = new MemoryStream(); + using MemoryStream memoryStream = new(); { // read length prefix int dataLength = BitConverter.ToInt32(zipBuffer, 0); @@ -78,7 +83,7 @@ namespace StardewModdingAPI.Web.Framework.Compression // read data byte[] buffer = new byte[dataLength]; memoryStream.Position = 0; - using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + using (GZipStream gZipStream = new(memoryStream, CompressionMode.Decompress)) gZipStream.Read(buffer, 0, buffer.Length); // return original string diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs index a000865e..ef2d5696 100644 --- a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Compression { /// <summary>Handles GZip compression logic.</summary> @@ -12,6 +14,7 @@ namespace StardewModdingAPI.Web.Framework.Compression /// <summary>Decompress a string.</summary> /// <param name="rawText">The compressed text.</param> - string DecompressString(string rawText); + [return: NotNullIfNotNull("rawText")] + string? DecompressString(string? rawText); } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 878130bf..b582b2b0 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -10,17 +10,17 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Generic ****/ /// <summary>The user agent for API clients, where {0} is the SMAPI version.</summary> - public string UserAgent { get; set; } + public string UserAgent { get; set; } = null!; /**** ** Azure ****/ /// <summary>The connection string for the Azure Blob storage account.</summary> - public string AzureBlobConnectionString { get; set; } + public string? AzureBlobConnectionString { get; set; } /// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary> - public string AzureBlobTempContainer { get; set; } + public string AzureBlobTempContainer { get; set; } = null!; /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary> public int AzureBlobTempExpiryDays { get; set; } @@ -30,65 +30,65 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Chucklefish ****/ /// <summary>The base URL for the Chucklefish mod site.</summary> - public string ChucklefishBaseUrl { get; set; } + public string ChucklefishBaseUrl { get; set; } = null!; /// <summary>The URL for a mod page on the Chucklefish mod site excluding the <see cref="GitHubBaseUrl"/>, where {0} is the mod ID.</summary> - public string ChucklefishModPageUrlFormat { get; set; } + public string ChucklefishModPageUrlFormat { get; set; } = null!; /**** ** CurseForge ****/ /// <summary>The base URL for the CurseForge API.</summary> - public string CurseForgeBaseUrl { get; set; } + public string CurseForgeBaseUrl { get; set; } = null!; /**** ** GitHub ****/ /// <summary>The base URL for the GitHub API.</summary> - public string GitHubBaseUrl { get; set; } + public string GitHubBaseUrl { get; set; } = null!; /// <summary>The Accept header value expected by the GitHub API.</summary> - public string GitHubAcceptHeader { get; set; } + public string GitHubAcceptHeader { get; set; } = null!; /// <summary>The username with which to authenticate to the GitHub API (if any).</summary> - public string GitHubUsername { get; set; } + public string? GitHubUsername { get; set; } /// <summary>The password with which to authenticate to the GitHub API (if any).</summary> - public string GitHubPassword { get; set; } + public string? GitHubPassword { get; set; } /**** ** ModDrop ****/ /// <summary>The base URL for the ModDrop API.</summary> - public string ModDropApiUrl { get; set; } + public string ModDropApiUrl { get; set; } = null!; /// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary> - public string ModDropModPageUrl { get; set; } + public string ModDropModPageUrl { get; set; } = null!; /**** ** Nexus Mods ****/ /// <summary>The base URL for the Nexus Mods API.</summary> - public string NexusBaseUrl { get; set; } + public string NexusBaseUrl { get; set; } = null!; /// <summary>The URL for a Nexus mod page for the user, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> - public string NexusModUrlFormat { get; set; } + public string NexusModUrlFormat { get; set; } = null!; /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary> - public string NexusModScrapeUrlFormat { get; set; } + public string NexusModScrapeUrlFormat { get; set; } = null!; /// <summary>The Nexus API authentication key.</summary> - public string NexusApiKey { get; set; } + public string? NexusApiKey { get; set; } /**** ** Pastebin ****/ /// <summary>The base URL for the Pastebin API.</summary> - public string PastebinBaseUrl { get; set; } + public string PastebinBaseUrl { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs index f382d7b5..e46ecf2b 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModOverrideConfig.cs @@ -4,12 +4,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels internal class ModOverrideConfig { /// <summary>The unique ID from the mod's manifest.</summary> - public string ID { get; set; } + public string ID { get; set; } = null!; /// <summary>Whether to allow non-standard versions.</summary> public bool AllowNonStandardVersions { get; set; } /// <summary>The mod page URL to use regardless of which site has the update, or <c>null</c> to use the site URL.</summary> - public string SetUrl { get; set; } + public string? SetUrl { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index aea695b8..c3b136e8 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The config settings for mod update checks.</summary> @@ -6,16 +8,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> + /// <summary>The number of minutes successful update checks should be cached before re-fetching them.</summary> public int SuccessCacheMinutes { get; set; } - /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> + /// <summary>The number of minutes failed update checks should be cached before re-fetching them.</summary> public int ErrorCacheMinutes { get; set; } /// <summary>Update-check metadata to override.</summary> - public ModOverrideConfig[] ModOverrides { get; set; } + public ModOverrideConfig[] ModOverrides { get; set; } = Array.Empty<ModOverrideConfig>(); /// <summary>The update-check config for SMAPI's own update checks.</summary> - public SmapiInfoConfig SmapiInfo { get; set; } + public SmapiInfoConfig SmapiInfo { get; set; } = null!; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index 664dbef3..62685e47 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -7,9 +7,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels ** Accessors *********/ /// <summary>A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format.</summary> - public string OtherBlurb { get; set; } + public string? OtherBlurb { get; set; } /// <summary>A list of supports to credit on the main page, in Markdown format.</summary> - public string SupporterList { get; set; } + public string? SupporterList { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs index d69fabb3..a95e0048 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SmapiInfoConfig.cs @@ -1,15 +1,17 @@ +using System; + namespace StardewModdingAPI.Web.Framework.ConfigModels { /// <summary>The update-check config for SMAPI's own update checks.</summary> internal class SmapiInfoConfig { /// <summary>The mod ID used for SMAPI update checks.</summary> - public string ID { get; set; } + public string ID { get; set; } = null!; /// <summary>The default update key used for SMAPI update checks.</summary> - public string DefaultUpdateKey { get; set; } + public string DefaultUpdateKey { get; set; } = null!; /// <summary>The update keys to add for SMAPI update checks when the player has a beta version installed.</summary> - public string[] AddBetaUpdateKeys { get; set; } + public string[] AddBetaUpdateKeys { get; set; } = Array.Empty<string>(); } } diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs index 5305b142..62a23155 100644 --- a/src/SMAPI.Web/Framework/Extensions.cs +++ b/src/SMAPI.Web/Framework/Extensions.cs @@ -26,10 +26,10 @@ namespace StardewModdingAPI.Web.Framework /// <param name="values">An object that contains route values.</param> /// <param name="absoluteUrl">Get an absolute URL instead of a server-relative path/</param> /// <returns>The generated URL.</returns> - public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false) + public static string? PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object? values = null, bool absoluteUrl = false) { // get route values - RouteValueDictionary valuesDict = new RouteValueDictionary(values); + RouteValueDictionary valuesDict = new(values); foreach (var value in helper.ActionContext.RouteData.Values) { if (!valuesDict.ContainsKey(value.Key)) @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework } // get relative URL - string url = helper.Action(action, controller, valuesDict); + string? url = helper.Action(action, controller, valuesDict); if (url == null && action.EndsWith("Async")) url = helper.Action(action[..^"Async".Length], controller, valuesDict); @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework if (absoluteUrl) { HttpRequest request = helper.ActionContext.HttpContext.Request; - Uri baseUri = new Uri($"{request.Scheme}://{request.Host}"); + Uri baseUri = new($"{request.Scheme}://{request.Host}"); url = new Uri(baseUri, url).ToString(); } @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="value">The value to serialize.</param> /// <returns>The serialized JSON.</returns> /// <remarks>This bypasses unnecessary validation (e.g. not allowing null values) in <see cref="IJsonHelper.Serialize"/>.</remarks> - public static IHtmlContent ForJson(this RazorPageBase page, object value) + public static IHtmlContent ForJson(this RazorPageBase page, object? value) { string json = JsonConvert.SerializeObject(value); return new HtmlString(json); diff --git a/src/SMAPI.Web/Framework/IModDownload.cs b/src/SMAPI.Web/Framework/IModDownload.cs index dc058bcb..fe171785 100644 --- a/src/SMAPI.Web/Framework/IModDownload.cs +++ b/src/SMAPI.Web/Framework/IModDownload.cs @@ -3,13 +3,16 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Generic metadata about a file download on a mod page.</summary> internal interface IModDownload { + /********* + ** Accessors + *********/ /// <summary>The download's display name.</summary> string Name { get; } /// <summary>The download's description.</summary> - string Description { get; } + string? Description { get; } /// <summary>The download's file version.</summary> - string Version { get; } + string? Version { get; } } } diff --git a/src/SMAPI.Web/Framework/IModPage.cs b/src/SMAPI.Web/Framework/IModPage.cs index e66d401f..4d0a8d61 100644 --- a/src/SMAPI.Web/Framework/IModPage.cs +++ b/src/SMAPI.Web/Framework/IModPage.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using StardewModdingAPI.Toolkit.Framework.UpdateData; namespace StardewModdingAPI.Web.Framework @@ -16,13 +17,13 @@ namespace StardewModdingAPI.Web.Framework string Id { get; } /// <summary>The mod name.</summary> - string Name { get; } + string? Name { get; } /// <summary>The mod's semantic version number.</summary> - string Version { get; } + string? Version { get; } /// <summary>The mod's web URL.</summary> - string Url { get; } + string? Url { get; } /// <summary>The mod downloads.</summary> IModDownload[] Downloads { get; } @@ -31,7 +32,12 @@ namespace StardewModdingAPI.Web.Framework RemoteModStatus Status { get; } /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - string Error { get; } + string? Error { get; } + + /// <summary>Whether the mod data is valid.</summary> + [MemberNotNullWhen(true, nameof(IModPage.Name), nameof(IModPage.Url))] + [MemberNotNullWhen(false, nameof(IModPage.Error))] + bool IsValid { get; } /********* @@ -42,7 +48,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="version">The mod's semantic version number.</param> /// <param name="url">The mod's web URL.</param> /// <param name="downloads">The mod downloads.</param> - IModPage SetInfo(string name, string version, string url, IEnumerable<IModDownload> downloads); + IModPage SetInfo(string name, string? version, string url, IEnumerable<IModDownload> downloads); /// <summary>Set a mod fetch error.</summary> /// <param name="status">The mod availability status on the remote site.</param> diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs index 385c0c91..3c1405eb 100644 --- a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Web.Framework ** Fields *********/ /// <summary>An authorization filter that allows local requests.</summary> - private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new LocalRequestsOnlyAuthorizationFilter(); + private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new(); /********* diff --git a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs index 992876ef..a1384b8f 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogMessageBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Text; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -11,7 +12,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Fields *********/ /// <summary>The local time when the next log was posted.</summary> - public string Time { get; set; } + public string? Time { get; set; } /// <summary>The log level for the next log message.</summary> public LogLevel Level { get; set; } @@ -20,16 +21,17 @@ namespace StardewModdingAPI.Web.Framework.LogParsing public int ScreenId { get; set; } /// <summary>The mod name for the next log message.</summary> - public string Mod { get; set; } + public string? Mod { get; set; } /// <summary>The text for the next log message.</summary> - private readonly StringBuilder Text = new StringBuilder(); + private readonly StringBuilder Text = new(); /********* ** Accessors *********/ /// <summary>Whether the next log message has been started.</summary> + [MemberNotNullWhen(true, nameof(LogMessageBuilder.Time), nameof(LogMessageBuilder.Mod))] public bool Started { get; private set; } @@ -70,19 +72,18 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } /// <summary>Get a log message for the accumulated values.</summary> - public LogMessage Build() + public LogMessage? Build() { if (!this.Started) return null; - return new LogMessage - { - Time = this.Time, - Level = this.Level, - ScreenId = this.ScreenId, - Mod = this.Mod, - Text = this.Text.ToString() - }; + return new LogMessage( + time: this.Time, + level: this.Level, + screenId: this.ScreenId, + mod: this.Mod, + text: this.Text.ToString() + ); } /// <summary>Reset to start a new log message.</summary> diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs index 5d4c8c08..3f815e3e 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParseException.cs @@ -10,6 +10,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing *********/ /// <summary>Construct an instance.</summary> /// <param name="message">The user-friendly error message.</param> - public LogParseException(string message) : base(message) { } + public LogParseException(string message) + : base(message) { } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 887d0105..55272b23 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -14,38 +14,38 @@ namespace StardewModdingAPI.Web.Framework.LogParsing ** Fields *********/ /// <summary>A regex pattern matching the start of a SMAPI message.</summary> - private readonly Regex MessageHeaderPattern = new Regex(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex MessageHeaderPattern = new(@"^\[(?<time>\d\d[:\.]\d\d[:\.]\d\d) (?<level>[a-z]+)(?: +screen_(?<screen>\d+))? +(?<modName>[^\]]+)\] ", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's initial platform info message.</summary> - private readonly Regex InfoLinePattern = new Regex(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex InfoLinePattern = new(@"^SMAPI (?<apiVersion>.+) with Stardew Valley (?<gameVersion>.+) on (?<os>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's mod folder path line.</summary> - private readonly Regex ModPathPattern = new Regex(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModPathPattern = new(@"^Mods go here: (?<path>.+)", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's log timestamp line.</summary> - private readonly Regex LogStartedAtPattern = new Regex(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex LogStartedAtPattern = new(@"^Log started at (?<timestamp>.+) UTC", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod list.</summary> - private readonly Regex ModListStartPattern = new Regex(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListStartPattern = new(@"^Loaded \d+ mods:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary> /// <remarks>The author name and description are optional.</remarks> - private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary> - private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListStartPattern = new(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> - private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ContentPackListEntryPattern = new(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListStartPattern = new(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary> - private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex ModUpdateListEntryPattern = new(@"^ (?<name>.+) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /// <summary>A regex pattern matching SMAPI's update line.</summary> - private readonly Regex SmapiUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SmapiUpdatePattern = new(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing *********/ /// <summary>Parse SMAPI log text.</summary> /// <param name="logText">The SMAPI log text.</param> - public ParsedLog Parse(string logText) + public ParsedLog Parse(string? logText) { try { @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // init log - ParsedLog log = new ParsedLog + ParsedLog log = new() { IsValid = true, RawText = logText, @@ -77,8 +77,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing }; // parse log messages - LogModInfo smapiMod = new LogModInfo { Name = "SMAPI", Author = "Pathoschild", Description = "", Loaded = true }; - LogModInfo gameMod = new LogModInfo { Name = "game", Author = "", Description = "", Loaded = true }; + LogModInfo smapiMod = new(name: "SMAPI", author: "Pathoschild", version: "", description: "", loaded: true); + LogModInfo gameMod = new(name: "game", author: "", version: "", description: "", loaded: true); IDictionary<string, List<LogModInfo>> mods = new Dictionary<string, List<LogModInfo>>(); bool inModList = false; bool inContentPackList = false; @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing default: if (mods.TryGetValue(message.Mod, out var entries)) { - foreach (var entry in entries) + foreach (LogModInfo entry in entries) entry.Errors++; } break; @@ -131,9 +131,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string author = match.Groups["author"].Value; string description = match.Groups["description"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, loaded: true)); message.Section = LogSection.ModsList; } @@ -154,9 +154,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing string description = match.Groups["description"].Value; string forMod = match.Groups["for"].Value; - if (!mods.TryGetValue(name, out List<LogModInfo> entries)) + if (!mods.TryGetValue(name, out List<LogModInfo>? entries)) mods[name] = entries = new List<LogModInfo>(); - entries.Add(new LogModInfo { Name = name, Author = author, Version = version, Description = description, ContentPackFor = forMod, Loaded = true }); + entries.Add(new LogModInfo(name: name, author: author, version: version, description: description, contentPackFor: forMod, loaded: true)); message.Section = LogSection.ContentPackList; } @@ -177,23 +177,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing if (mods.TryGetValue(name, out var entries)) { - foreach (var entry in entries) - { - entry.UpdateLink = link; - entry.UpdateVersion = version; - } + foreach (LogModInfo entry in entries) + entry.SetUpdate(version, link); } message.Section = LogSection.ModUpdateList; } - else if (message.Level == LogLevel.Alert && this.SmapiUpdatePattern.IsMatch(message.Text)) { Match match = this.SmapiUpdatePattern.Match(message.Text); string version = match.Groups["version"].Value; string link = match.Groups["link"].Value; - smapiMod.UpdateVersion = version; - smapiMod.UpdateLink = link; + + smapiMod.SetUpdate(version, link); } // platform info line @@ -203,7 +199,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing log.ApiVersion = match.Groups["apiVersion"].Value; log.GameVersion = match.Groups["gameVersion"].Value; log.OperatingSystem = match.Groups["os"].Value; - smapiMod.Version = log.ApiVersion; + smapiMod.OverrideVersion(log.ApiVersion); } // mod path line @@ -211,9 +207,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { Match match = this.ModPathPattern.Match(message.Text); log.ModPath = match.Groups["path"].Value; - int lastDelimiterPos = log.ModPath.LastIndexOfAny(new char[] { '/', '\\' }); + int lastDelimiterPos = log.ModPath.LastIndexOfAny(new[] { '/', '\\' }); log.GamePath = lastDelimiterPos >= 0 - ? log.ModPath.Substring(0, lastDelimiterPos) + ? log.ModPath[..lastDelimiterPos] : log.ModPath; } @@ -227,7 +223,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } // finalize log - gameMod.Version = log.GameVersion; + if (log.GameVersion != null) + gameMod.OverrideVersion(log.GameVersion); log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.SelectMany(p => p).OrderBy(p => p.Name)).ToArray(); return log; } @@ -259,7 +256,8 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <param name="messages">The messages to filter.</param> private IEnumerable<LogMessage> CollapseRepeats(IEnumerable<LogMessage> messages) { - LogMessage next = null; + LogMessage? next = null; + foreach (LogMessage message in messages) { // new message @@ -280,7 +278,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing yield return next; next = message; } - yield return next; + + if (next != null) + yield return next; } /// <summary>Split a SMAPI log into individual log messages.</summary> @@ -288,12 +288,12 @@ namespace StardewModdingAPI.Web.Framework.LogParsing /// <exception cref="LogParseException">The log text can't be parsed successfully.</exception> private IEnumerable<LogMessage> GetMessages(string logText) { - LogMessageBuilder builder = new LogMessageBuilder(); - using StringReader reader = new StringReader(logText); + LogMessageBuilder builder = new(); + using StringReader reader = new(logText); while (true) { // read line - string line = reader.ReadLine(); + string? line = reader.ReadLine(); if (line == null) break; @@ -306,17 +306,17 @@ namespace StardewModdingAPI.Web.Framework.LogParsing { if (builder.Started) { - yield return builder.Build(); + yield return builder.Build()!; builder.Clear(); } - var screenGroup = header.Groups["screen"]; + Group screenGroup = header.Groups["screen"]; builder.Start( time: header.Groups["time"].Value, level: Enum.Parse<LogLevel>(header.Groups["level"].Value, ignoreCase: true), screenId: screenGroup.Success ? int.Parse(screenGroup.Value) : 0, // main player is always screen ID 0 mod: header.Groups["modName"].Value, - text: line.Substring(header.Length) + text: line[header.Length..] ); } else @@ -330,7 +330,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing // end last message if (builder.Started) - yield return builder.Build(); + yield return builder.Build()!; } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs index 1e08be78..7a5f32e0 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogMessage.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.LogParsing.Models { /// <summary>A parsed log message.</summary> @@ -7,19 +9,19 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The local time when the log was posted.</summary> - public string Time { get; set; } + public string Time { get; } /// <summary>The log level.</summary> - public LogLevel Level { get; set; } + public LogLevel Level { get; } /// <summary>The screen ID in split-screen mode.</summary> - public int ScreenId { get; set; } + public int ScreenId { get; } /// <summary>The mod name.</summary> - public string Mod { get; set; } + public string Mod { get; } /// <summary>The log text.</summary> - public string Text { get; set; } + public string Text { get; } /// <summary>The number of times this message was repeated consecutively.</summary> public int Repeated { get; set; } @@ -28,6 +30,32 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models public LogSection? Section { get; set; } /// <summary>Whether this message is the first one of its section.</summary> + [MemberNotNullWhen(true, nameof(LogMessage.Section))] public bool IsStartOfSection { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance/</summary> + /// <param name="time">The local time when the log was posted.</param> + /// <param name="level">The log level.</param> + /// <param name="screenId">The screen ID in split-screen mode.</param> + /// <param name="mod">The mod name.</param> + /// <param name="text">The log text.</param> + /// <param name="repeated">The number of times this message was repeated consecutively.</param> + /// <param name="section">The section that this log message belongs to.</param> + /// <param name="isStartOfSection">Whether this message is the first one of its section.</param> + public LogMessage(string time, LogLevel level, int screenId, string mod, string text, int repeated = 0, LogSection? section = null, bool isStartOfSection = false) + { + this.Time = time; + this.Level = level; + this.ScreenId = screenId; + this.Mod = mod; + this.Text = text; + this.Repeated = repeated; + this.Section = section; + this.IsStartOfSection = isStartOfSection; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs index 067e4df4..a6b9165c 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/LogModInfo.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.LogParsing.Models { /// <summary>Metadata about a mod or content pack in the log.</summary> @@ -7,33 +9,81 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string Name { get; } /// <summary>The mod author.</summary> - public string Author { get; set; } - - /// <summary>The update version.</summary> - public string UpdateVersion { get; set; } - - /// <summary>The update link.</summary> - public string UpdateLink { get; set; } + public string Author { get; } /// <summary>The mod version.</summary> - public string Version { get; set; } + public string Version { get; private set; } /// <summary>The mod description.</summary> - public string Description { get; set; } + public string Description { get; } + + /// <summary>The update version.</summary> + public string? UpdateVersion { get; private set; } + + /// <summary>The update link.</summary> + public string? UpdateLink { get; private set; } /// <summary>The name of the mod for which this is a content pack (if applicable).</summary> - public string ContentPackFor { get; set; } + public string? ContentPackFor { get; } /// <summary>The number of errors logged by this mod.</summary> public int Errors { get; set; } /// <summary>Whether the mod was loaded into the game.</summary> - public bool Loaded { get; set; } + public bool Loaded { get; } /// <summary>Whether the mod has an update available.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.UpdateVersion), nameof(LogModInfo.UpdateLink))] public bool HasUpdate => this.UpdateVersion != null && this.Version != this.UpdateVersion; + + /// <summary>Whether the mod is a content pack for another mod.</summary> + [MemberNotNullWhen(true, nameof(LogModInfo.ContentPackFor))] + public bool IsContentPack => !string.IsNullOrWhiteSpace(this.ContentPackFor); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="author">The mod author.</param> + /// <param name="version">The mod version.</param> + /// <param name="description">The mod description.</param> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + /// <param name="contentPackFor">The name of the mod for which this is a content pack (if applicable).</param> + /// <param name="errors">The number of errors logged by this mod.</param> + /// <param name="loaded">Whether the mod was loaded into the game.</param> + public LogModInfo(string name, string author, string version, string description, string? updateVersion = null, string? updateLink = null, string? contentPackFor = null, int errors = 0, bool loaded = true) + { + this.Name = name; + this.Author = author; + this.Version = version; + this.Description = description; + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + this.ContentPackFor = contentPackFor; + this.Errors = errors; + this.Loaded = loaded; + } + + /// <summary>Add an update alert for this mod.</summary> + /// <param name="updateVersion">The update version.</param> + /// <param name="updateLink">The update link.</param> + public void SetUpdate(string updateVersion, string updateLink) + { + this.UpdateVersion = updateVersion; + this.UpdateLink = updateLink; + } + + /// <summary>Override the version number, for cases like SMAPI itself where the version is only known later during parsing.</summary> + /// <param name="version">The new mod version.</param> + public void OverrideVersion(string version) + { + this.Version = version; + } } } diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs index 87b20eb0..6951e434 100644 --- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs +++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.LogParsing.Models { @@ -12,39 +13,40 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models ** Metadata ****/ /// <summary>Whether the log file was successfully parsed.</summary> + [MemberNotNullWhen(true, nameof(ParsedLog.RawText))] public bool IsValid { get; set; } /// <summary>An error message indicating why the log file is invalid.</summary> - public string Error { get; set; } + public string? Error { get; set; } /// <summary>The raw log text.</summary> - public string RawText { get; set; } + public string? RawText { get; set; } /**** ** Log data ****/ /// <summary>The SMAPI version.</summary> - public string ApiVersion { get; set; } + public string? ApiVersion { get; set; } /// <summary>The game version.</summary> - public string GameVersion { get; set; } + public string? GameVersion { get; set; } /// <summary>The player's operating system.</summary> - public string OperatingSystem { get; set; } + public string? OperatingSystem { get; set; } /// <summary>The game install path.</summary> - public string GamePath { get; set; } + public string? GamePath { get; set; } /// <summary>The mod folder path.</summary> - public string ModPath { get; set; } + public string? ModPath { get; set; } /// <summary>The ISO 8601 timestamp when the log was started.</summary> public DateTimeOffset Timestamp { get; set; } /// <summary>Metadata about installed mods and content packs.</summary> - public LogModInfo[] Mods { get; set; } = new LogModInfo[0]; + public LogModInfo[] Mods { get; set; } = Array.Empty<LogModInfo>(); /// <summary>The log messages.</summary> - public LogMessage[] Messages { get; set; } + public LogMessage[] Messages { get; set; } = Array.Empty<LogMessage>(); } } diff --git a/src/SMAPI.Web/Framework/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModInfoModel.cs index 7845b8c5..e70b60bf 100644 --- a/src/SMAPI.Web/Framework/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModInfoModel.cs @@ -1,4 +1,5 @@ -using StardewModdingAPI.Web.Framework.Clients; +using System.Diagnostics.CodeAnalysis; +using Newtonsoft.Json; namespace StardewModdingAPI.Web.Framework { @@ -9,22 +10,22 @@ namespace StardewModdingAPI.Web.Framework ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; private set; } + + /// <summary>The mod's web URL.</summary> + public string? Url { get; private set; } /// <summary>The mod's latest version.</summary> - public ISemanticVersion Version { get; set; } + public ISemanticVersion? Version { get; private set; } /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> - public ISemanticVersion PreviewVersion { get; set; } - - /// <summary>The mod's web URL.</summary> - public string Url { get; set; } + public ISemanticVersion? PreviewVersion { get; private set; } /// <summary>The mod availability status on the remote site.</summary> - public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + public RemoteModStatus Status { get; private set; } /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> - public string Error { get; set; } + public string? Error { get; private set; } /********* @@ -35,19 +36,24 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Construct an instance.</summary> /// <param name="name">The mod name.</param> + /// <param name="url">The mod's web URL.</param> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - /// <param name="url">The mod's web URL.</param> - public ModInfoModel(string name, ISemanticVersion version, string url, ISemanticVersion previewVersion = null) + /// <param name="status">The mod availability status on the remote site.</param> + /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> + [JsonConstructor] + public ModInfoModel(string name, string url, ISemanticVersion? version, ISemanticVersion? previewVersion = null, RemoteModStatus status = RemoteModStatus.Ok, string? error = null) { this .SetBasicInfo(name, url) - .SetVersions(version, previewVersion); + .SetVersions(version!, previewVersion) + .SetError(status, error!); } /// <summary>Set the basic mod info.</summary> /// <param name="name">The mod name.</param> /// <param name="url">The mod's web URL.</param> + [MemberNotNull(nameof(ModInfoModel.Name), nameof(ModInfoModel.Url))] public ModInfoModel SetBasicInfo(string name, string url) { this.Name = name; @@ -59,7 +65,8 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Set the mod version info.</summary> /// <param name="version">The semantic version for the mod's latest release.</param> /// <param name="previewVersion">The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</param> - public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion previewVersion = null) + [MemberNotNull(nameof(ModInfoModel.Version))] + public ModInfoModel SetVersions(ISemanticVersion version, ISemanticVersion? previewVersion = null) { this.Version = version; this.PreviewVersion = previewVersion; diff --git a/src/SMAPI.Web/Framework/ModSiteManager.cs b/src/SMAPI.Web/Framework/ModSiteManager.cs index a2b92aa4..674b9ffc 100644 --- a/src/SMAPI.Web/Framework/ModSiteManager.cs +++ b/src/SMAPI.Web/Framework/ModSiteManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -34,12 +35,15 @@ namespace StardewModdingAPI.Web.Framework /// <param name="updateKey">The namespaced update key.</param> public async Task<IModPage> GetModPageAsync(UpdateKey updateKey) { + if (!updateKey.LooksValid) + return new GenericModPage(updateKey.Site, updateKey.ID!).SetError(RemoteModStatus.DoesNotExist, $"Invalid update key '{updateKey}'."); + // get site - if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient client)) + if (!this.ModSites.TryGetValue(updateKey.Site, out IModSiteClient? client)) return new GenericModPage(updateKey.Site, updateKey.ID).SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Site}'. Expected one of [{string.Join(", ", this.ModSites.Keys)}]."); // fetch mod - IModPage mod; + IModPage? mod; try { mod = await client.GetModData(updateKey.ID); @@ -58,39 +62,42 @@ namespace StardewModdingAPI.Web.Framework /// <param name="subkey">The optional update subkey to match in available files. (If no file names or descriptions contain the subkey, it'll be ignored.)</param> /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="allowNonStandardVersions">Whether to allow non-standard versions.</param> - public ModInfoModel GetPageVersions(IModPage page, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions) + public ModInfoModel GetPageVersions(IModPage page, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions) { // get base model - ModInfoModel model = new ModInfoModel() - .SetBasicInfo(page.Name, page.Url) - .SetError(page.Status, page.Error); - if (page.Status != RemoteModStatus.Ok) + ModInfoModel model = new(); + if (page.IsValid) + model.SetBasicInfo(page.Name, page.Url); + else + { + model.SetError(page.Status, page.Error); return model; + } // fetch versions - bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion mainVersion, out ISemanticVersion previewVersion); + bool hasVersions = this.TryGetLatestVersions(page, subkey, allowNonStandardVersions, mapRemoteVersions, out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion); if (!hasVersions && subkey != null) hasVersions = this.TryGetLatestVersions(page, null, allowNonStandardVersions, mapRemoteVersions, out mainVersion, out previewVersion); if (!hasVersions) return model.SetError(RemoteModStatus.InvalidData, $"The {page.Site} mod with ID '{page.Id}' has no valid versions."); // return info - return model.SetVersions(mainVersion, previewVersion); + return model.SetVersions(mainVersion!, previewVersion); } /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to parse.</param> /// <param name="map">Changes to apply to the raw version, if any.</param> /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - public ISemanticVersion GetMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + public ISemanticVersion? GetMappedVersion(string? version, ChangeDescriptor? map, bool allowNonStandard) { // try mapped version - string rawNewVersion = this.GetRawMappedVersion(version, map, allowNonStandard); - if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion parsedNew)) + string? rawNewVersion = this.GetRawMappedVersion(version, map); + if (SemanticVersion.TryParse(rawNewVersion, allowNonStandard, out ISemanticVersion? parsedNew)) return parsedNew; // return original version - return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion parsedOld) + return SemanticVersion.TryParse(version, allowNonStandard, out ISemanticVersion? parsedOld) ? parsedOld : null; } @@ -106,31 +113,31 @@ namespace StardewModdingAPI.Web.Framework /// <param name="mapRemoteVersions">The changes to apply to remote versions for update checks.</param> /// <param name="main">The main mod version.</param> /// <param name="preview">The latest prerelease version, if newer than <paramref name="main"/>.</param> - private bool TryGetLatestVersions(IModPage mod, string subkey, bool allowNonStandardVersions, ChangeDescriptor mapRemoteVersions, out ISemanticVersion main, out ISemanticVersion preview) + private bool TryGetLatestVersions(IModPage? mod, string? subkey, bool allowNonStandardVersions, ChangeDescriptor? mapRemoteVersions, [NotNullWhen(true)] out ISemanticVersion? main, out ISemanticVersion? preview) { main = null; preview = null; // parse all versions from the mod page - IEnumerable<(string name, string description, ISemanticVersion version)> GetAllVersions() + IEnumerable<(string? name, string? description, ISemanticVersion? version)> GetAllVersions() { if (mod != null) { - ISemanticVersion ParseAndMapVersion(string raw) + ISemanticVersion? ParseAndMapVersion(string? raw) { raw = this.NormalizeVersion(raw); return this.GetMappedVersion(raw, mapRemoteVersions, allowNonStandardVersions); } // get mod version - ISemanticVersion modVersion = ParseAndMapVersion(mod.Version); + ISemanticVersion? modVersion = ParseAndMapVersion(mod.Version); if (modVersion != null) yield return (name: null, description: null, version: ParseAndMapVersion(mod.Version)); // get file versions foreach (IModDownload download in mod.Downloads) { - ISemanticVersion cur = ParseAndMapVersion(download.Version); + ISemanticVersion? cur = ParseAndMapVersion(download.Version); if (cur != null) yield return (download.Name, download.Description, cur); } @@ -141,15 +148,15 @@ namespace StardewModdingAPI.Web.Framework .ToArray(); // get main + preview versions - void TryGetVersions(out ISemanticVersion mainVersion, out ISemanticVersion previewVersion, Func<(string name, string description, ISemanticVersion version), bool> filter = null) + void TryGetVersions([NotNullWhen(true)] out ISemanticVersion? mainVersion, out ISemanticVersion? previewVersion, Func<(string? name, string? description, ISemanticVersion? version), bool>? filter = null) { mainVersion = null; previewVersion = null; // get latest main + preview version - foreach (var entry in versions) + foreach ((string? name, string? description, ISemanticVersion? version) entry in versions) { - if (filter?.Invoke(entry) == false) + if (entry.version is null || filter?.Invoke(entry) == false) continue; if (entry.version.IsPrerelease()) @@ -158,7 +165,7 @@ namespace StardewModdingAPI.Web.Framework mainVersion ??= entry.version; if (mainVersion != null) - break; // any other values will be older + break; // any others will be older since entries are sorted by version } // normalize values @@ -181,8 +188,7 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Get a semantic local version for update checks.</summary> /// <param name="version">The version to map.</param> /// <param name="map">Changes to apply to the raw version, if any.</param> - /// <param name="allowNonStandard">Whether to allow non-standard versions.</param> - private string GetRawMappedVersion(string version, ChangeDescriptor map, bool allowNonStandard) + private string? GetRawMappedVersion(string? version, ChangeDescriptor? map) { if (version == null || map?.HasChanges != true) return version; @@ -195,7 +201,7 @@ namespace StardewModdingAPI.Web.Framework /// <summary>Normalize a version string.</summary> /// <param name="version">The version to normalize.</param> - private string NormalizeVersion(string version) + private string? NormalizeVersion(string? version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs index d75ee791..7b8f0ec9 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectHostsToUrlsRule.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules ** Fields *********/ /// <summary>Maps a lowercase hostname to the resulting redirect URL.</summary> - private readonly Func<string, string> Map; + private readonly Func<string, string?> Map; /********* @@ -20,7 +20,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Construct an instance.</summary> /// <param name="statusCode">The status code to use for redirects.</param> /// <param name="map">Hostnames mapped to the resulting redirect URL.</param> - public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string> map) + public RedirectHostsToUrlsRule(HttpStatusCode statusCode, Func<string, string?> map) { this.StatusCode = statusCode; this.Map = map ?? throw new ArgumentNullException(nameof(map)); @@ -33,12 +33,10 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { // get requested host - string host = context.HttpContext.Request.Host.Host; - if (host == null) - return null; + string? host = context.HttpContext.Request.Host.Host; // get new host host = this.Map(host); diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs index 6e81c4ca..b46e8f69 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectMatchRule.cs @@ -22,7 +22,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <param name="context">The rewrite context.</param> public void ApplyRule(RewriteContext context) { - string newUrl = this.GetNewUrl(context); + string? newUrl = this.GetNewUrl(context); if (newUrl == null) return; @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected abstract string GetNewUrl(RewriteContext context); + protected abstract string? GetNewUrl(RewriteContext context); /// <summary>Get the full request URL.</summary> /// <param name="request">The request.</param> diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs index d9d44641..e691ffba 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectPathsToUrlsRule.cs @@ -37,9 +37,9 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { - string path = context.HttpContext.Request.Path.Value; + string? path = context.HttpContext.Request.Path.Value; if (!string.IsNullOrWhiteSpace(path)) { diff --git a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs index 2a503ae3..01807608 100644 --- a/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs +++ b/src/SMAPI.Web/Framework/RedirectRules/RedirectToHttpsRule.cs @@ -20,9 +20,9 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules *********/ /// <summary>Construct an instance.</summary> /// <param name="except">Matches requests which should be ignored.</param> - public RedirectToHttpsRule(Func<HttpRequest, bool> except = null) + public RedirectToHttpsRule(Func<HttpRequest, bool>? except = null) { - this.Except = except ?? (req => false); + this.Except = except ?? (_ => false); this.StatusCode = HttpStatusCode.RedirectKeepVerb; } @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Web.Framework.RedirectRules /// <summary>Get the new redirect URL.</summary> /// <param name="context">The rewrite context.</param> /// <returns>Returns the redirect URL, or <c>null</c> if the redirect doesn't apply.</returns> - protected override string GetNewUrl(RewriteContext context) + protected override string? GetNewUrl(RewriteContext context) { HttpRequest request = context.HttpContext.Request; if (request.IsHttps || this.Except(request)) diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs index c6f8bac1..effbbc9f 100644 --- a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs +++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs @@ -63,11 +63,11 @@ namespace StardewModdingAPI.Web.Framework.Storage BlobClient blob = this.GetAzureBlobClient(id); await blob.UploadAsync(stream); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } catch (Exception ex) { - return new UploadResult(false, null, ex.Message); + return new UploadResult(null, ex.Message); } } @@ -75,10 +75,10 @@ namespace StardewModdingAPI.Web.Framework.Storage else { string path = this.GetDevFilePath(id); - Directory.CreateDirectory(Path.GetDirectoryName(path)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); File.WriteAllText(path, content); - return new UploadResult(true, id, null); + return new UploadResult(id, null); } } @@ -103,26 +103,20 @@ namespace StardewModdingAPI.Web.Framework.Storage // fetch file Response<BlobDownloadInfo> response = await blob.DownloadAsync(); using BlobDownloadInfo result = response.Value; - using StreamReader reader = new StreamReader(result.Content); + using StreamReader reader = new(result.Content); DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ExpiryDays); string content = this.GzipHelper.DecompressString(reader.ReadToEnd()); // build model - return new StoredFileInfo - { - Success = true, - Content = content, - Expiry = expiry.UtcDateTime - }; + return new StoredFileInfo(content, expiry); } catch (RequestFailedException ex) { - return new StoredFileInfo - { - Error = ex.ErrorCode == "BlobNotFound" + return new StoredFileInfo( + error: ex.ErrorCode == "BlobNotFound" ? "There's no file with that ID." : $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})." - }; + ); } } @@ -130,15 +124,12 @@ namespace StardewModdingAPI.Web.Framework.Storage else { // get file - FileInfo file = new FileInfo(this.GetDevFilePath(id)); + FileInfo file = new(this.GetDevFilePath(id)); if (file.Exists && file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow) // expired file.Delete(); if (!file.Exists) { - return new StoredFileInfo - { - Error = "There's no file with that ID." - }; + return new StoredFileInfo(error: "There's no file with that ID."); } // renew @@ -149,13 +140,11 @@ namespace StardewModdingAPI.Web.Framework.Storage } // build model - return new StoredFileInfo - { - Success = true, - Content = File.ReadAllText(file.FullName), - Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays), - Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment." - }; + return new StoredFileInfo( + content: File.ReadAllText(file.FullName), + expiry: DateTime.UtcNow.AddDays(this.ExpiryDays), + warning: "This file was saved temporarily to the local computer. This should only happen in a local development environment." + ); } } @@ -164,12 +153,7 @@ namespace StardewModdingAPI.Web.Framework.Storage { PasteInfo response = await this.Pastebin.GetAsync(id); response.Content = this.GzipHelper.DecompressString(response.Content); - return new StoredFileInfo - { - Success = response.Success, - Content = response.Content, - Error = response.Error - }; + return new StoredFileInfo(response.Content, null, error: response.Error); } } @@ -177,8 +161,8 @@ namespace StardewModdingAPI.Web.Framework.Storage /// <param name="id">The file ID.</param> private BlobClient GetAzureBlobClient(string id) { - var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString); - var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); + BlobServiceClient azure = new(this.ClientsConfig.AzureBlobConnectionString); + BlobContainerClient container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer); return container.GetBlobClient($"uploads/{id}"); } diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs index 30676c88..bbbcf2a9 100644 --- a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs +++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs @@ -1,23 +1,52 @@ using System; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI.Web.Framework.Storage { /// <summary>The response for a get-file request.</summary> internal class StoredFileInfo { + /********* + ** Accessors + *********/ /// <summary>Whether the file was successfully fetched.</summary> - public bool Success { get; set; } + [MemberNotNullWhen(true, nameof(StoredFileInfo.Content))] + public bool Success => this.Content != null && this.Error == null; /// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary> - public string Content { get; set; } + public string? Content { get; } /// <summary>When the file will no longer be available.</summary> - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; } /// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary> - public string Warning { get; set; } + public string? Warning { get; } /// <summary>The error message if saving failed.</summary> - public string Error { get; set; } + public string? Error { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="content">The fetched file content (if <see cref="Success"/> is <c>true</c>).</param> + /// <param name="expiry">When the file will no longer be available.</param> + /// <param name="warning">The error message if saving succeeded, but a non-blocking issue was encountered.</param> + /// <param name="error">The error message if saving failed.</param> + public StoredFileInfo(string? content, DateTimeOffset? expiry, string? warning = null, string? error = null) + { + this.Content = content; + this.Expiry = expiry; + this.Warning = warning; + this.Error = error; + } + + /// <summary>Construct an instance.</summary> + /// <param name="error">The error message if saving failed.</param> + public StoredFileInfo(string error) + { + this.Error = error; + } } } diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs index 483c1769..92993d42 100644 --- a/src/SMAPI.Web/Framework/Storage/UploadResult.cs +++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Web.Framework.Storage { /// <summary>The result of an attempt to upload a file.</summary> @@ -7,25 +9,25 @@ namespace StardewModdingAPI.Web.Framework.Storage ** Accessors *********/ /// <summary>Whether the file upload succeeded.</summary> - public bool Succeeded { get; } + [MemberNotNullWhen(true, nameof(UploadResult.ID))] + [MemberNotNullWhen(false, nameof(UploadResult.UploadError))] + public bool Succeeded => this.ID != null && this.UploadError == null; /// <summary>The file ID, if applicable.</summary> - public string ID { get; } + public string? ID { get; } /// <summary>The upload error, if any.</summary> - public string UploadError { get; } + public string? UploadError { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="succeeded">Whether the file upload succeeded.</param> /// <param name="id">The file ID, if applicable.</param> /// <param name="uploadError">The upload error, if any.</param> - public UploadResult(bool succeeded, string id, string uploadError) + public UploadResult(string? id, string? uploadError) { - this.Succeeded = succeeded; this.ID = id; this.UploadError = uploadError; } diff --git a/src/SMAPI.Web/Framework/VersionConstraint.cs b/src/SMAPI.Web/Framework/VersionConstraint.cs index f0c57c41..1b1abd81 100644 --- a/src/SMAPI.Web/Framework/VersionConstraint.cs +++ b/src/SMAPI.Web/Framework/VersionConstraint.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Web.Framework /// <param name="values">A dictionary that contains the parameters for the URL.</param> /// <param name="routeDirection">An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated.</param> /// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns> - public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) + public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { if (routeKey == null) throw new ArgumentNullException(nameof(routeKey)); @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework throw new ArgumentNullException(nameof(values)); return - values.TryGetValue(routeKey, out object routeValue) + values.TryGetValue(routeKey, out object? routeValue) && routeValue is string routeStr && SemanticVersion.TryParse(routeStr, allowNonStandard: true, out _); } diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index d8561172..2693aa90 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -81,7 +81,7 @@ namespace StardewModdingAPI.Web // init Hangfire services - .AddHangfire((serv, config) => + .AddHangfire((_, config) => { config .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) @@ -100,7 +100,7 @@ namespace StardewModdingAPI.Web // init API clients { ApiClientsConfig api = this.Configuration.GetSection("ApiClients").Get<ApiClientsConfig>(); - string version = this.GetType().Assembly.GetName().Version.ToString(3); + string version = this.GetType().Assembly.GetName().Version!.ToString(3); string userAgent = string.Format(api.UserAgent, version); services.AddSingleton<IChucklefishClient>(new ChucklefishClient( @@ -128,14 +128,21 @@ namespace StardewModdingAPI.Web modUrlFormat: api.ModDropModPageUrl )); - services.AddSingleton<INexusClient>(new NexusClient( - webUserAgent: userAgent, - webBaseUrl: api.NexusBaseUrl, - webModUrlFormat: api.NexusModUrlFormat, - webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, - apiAppVersion: version, - apiKey: api.NexusApiKey - )); + if (!string.IsNullOrWhiteSpace(api.NexusApiKey)) + { + services.AddSingleton<INexusClient>(new NexusClient( + webUserAgent: userAgent, + webBaseUrl: api.NexusBaseUrl, + webModUrlFormat: api.NexusModUrlFormat, + webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, + apiAppVersion: version, + apiKey: api.NexusApiKey + )); + } + else + { + services.AddSingleton<INexusClient>(new DisabledNexusClient()); + } services.AddSingleton<IPastebinClient>(new PastebinClient( baseUrl: api.PastebinBaseUrl, diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs index d8d2d27f..098f18cc 100644 --- a/src/SMAPI.Web/ViewModels/IndexModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -7,26 +7,23 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The latest stable SMAPI version.</summary> - public IndexVersionModel StableVersion { get; set; } + public IndexVersionModel StableVersion { get; } /// <summary>A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format.</summary> - public string OtherBlurb { get; set; } + public string? OtherBlurb { get; } /// <summary>A list of supports to credit on the main page, in Markdown format.</summary> - public string SupporterList { get; set; } + public string? SupporterList { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public IndexModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The latest stable SMAPI version.</param> /// <param name="otherBlurb">A message to show below the download button (e.g. for details on downloading a beta version), in Markdown format.</param> /// <param name="supporterList">A list of supports to credit on the main page, in Markdown format.</param> - internal IndexModel(IndexVersionModel stableVersion, string otherBlurb, string supporterList) + internal IndexModel(IndexVersionModel stableVersion, string? otherBlurb, string? supporterList) { this.StableVersion = stableVersion; this.OtherBlurb = otherBlurb; diff --git a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs index 4f63b979..a76a5924 100644 --- a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs @@ -7,30 +7,27 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The release version.</summary> - public string Version { get; set; } + public string Version { get; } /// <summary>The Markdown description for the release.</summary> - public string Description { get; set; } + public string Description { get; } /// <summary>The main download URL.</summary> - public string DownloadUrl { get; set; } + public string DownloadUrl { get; } /// <summary>The for-developers download URL (not applicable for prerelease versions).</summary> - public string DevDownloadUrl { get; set; } + public string? DevDownloadUrl { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public IndexVersionModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="version">The release number.</param> /// <param name="description">The Markdown description for the release.</param> /// <param name="downloadUrl">The main download URL.</param> /// <param name="devDownloadUrl">The for-developers download URL (not applicable for prerelease versions).</param> - internal IndexVersionModel(string version, string description, string downloadUrl, string devDownloadUrl) + internal IndexVersionModel(string version, string description, string downloadUrl, string? devDownloadUrl) { this.Version = version; this.Description = description; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs index 62b95501..4d37d449 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs @@ -9,30 +9,27 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// <summary>The line number on which the error occurred.</summary> - public int Line { get; set; } + public int Line { get; } /// <summary>The field path in the JSON file where the error occurred.</summary> - public string Path { get; set; } + public string? Path { get; } /// <summary>A human-readable description of the error.</summary> - public string Message { get; set; } + public string Message { get; } /// <summary>The schema error type.</summary> - public ErrorType SchemaErrorType { get; set; } + public ErrorType SchemaErrorType { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public JsonValidatorErrorModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="line">The line number on which the error occurred.</param> /// <param name="path">The field path in the JSON file where the error occurred.</param> /// <param name="message">A human-readable description of the error.</param> /// <param name="schemaErrorType">The schema error type.</param> - public JsonValidatorErrorModel(int line, string path, string message, ErrorType schemaErrorType) + public JsonValidatorErrorModel(int line, string? path, string message, ErrorType schemaErrorType) { this.Line = line; this.Path = path; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index 0ea69911..85c2f44d 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -11,51 +11,48 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// <summary>Whether to show the edit view.</summary> - public bool IsEditView { get; set; } + public bool IsEditView { get; } /// <summary>The paste ID.</summary> - public string PasteID { get; set; } + public string? PasteID { get; } /// <summary>The schema name with which the JSON was validated.</summary> - public string SchemaName { get; set; } + public string? SchemaName { get; } /// <summary>The supported JSON schemas (names indexed by ID).</summary> - public readonly IDictionary<string, string> SchemaFormats; + public IDictionary<string, string> SchemaFormats { get; } /// <summary>The validated content.</summary> - public string Content { get; set; } + public string? Content { get; set; } /// <summary>The schema validation errors, if any.</summary> - public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; + public JsonValidatorErrorModel[] Errors { get; set; } = Array.Empty<JsonValidatorErrorModel>(); /// <summary>A non-blocking warning while uploading the file.</summary> - public string UploadWarning { get; set; } + public string? UploadWarning { get; set; } /// <summary>When the uploaded file will no longer be available.</summary> - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; set; } /// <summary>An error which occurred while uploading the JSON.</summary> - public string UploadError { get; set; } + public string? UploadError { get; set; } /// <summary>An error which occurred while parsing the JSON.</summary> - public string ParseError { get; set; } + public string? ParseError { get; set; } /// <summary>A web URL to the user-facing format documentation.</summary> - public string FormatUrl { get; set; } + public string? FormatUrl { get; set; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public JsonValidatorModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="pasteID">The stored file ID.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> /// <param name="isEditView">Whether to show the edit view.</param> - public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats, bool isEditView) + public JsonValidatorModel(string? pasteID, string? schemaName, IDictionary<string, string> schemaFormats, bool isEditView) { this.PasteID = pasteID; this.SchemaName = schemaName; @@ -67,7 +64,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /// <param name="content">The validated content.</param> /// <param name="expiry">When the uploaded file will no longer be available.</param> /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> - public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null) + public JsonValidatorModel SetContent(string content, DateTimeOffset? expiry, string? uploadWarning = null) { this.Content = content; this.Expiry = expiry; diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs index c8e851bf..1313264f 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs @@ -7,9 +7,9 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator ** Accessors *********/ /// <summary>The schema name with which to validate the JSON.</summary> - public string SchemaName { get; set; } + public string? SchemaName { get; set; } /// <summary>The raw content to validate.</summary> - public string Content { get; set; } + public string? Content { get; set; } } } diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index bea79eae..c39a9b0a 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.RegularExpressions; +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -14,47 +16,48 @@ namespace StardewModdingAPI.Web.ViewModels ** Fields *********/ /// <summary>A regex pattern matching characters to remove from a mod name to create the slug ID.</summary> - private readonly Regex SlugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly Regex SlugInvalidCharPattern = new("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase); /********* ** Accessors *********/ /// <summary>The paste ID.</summary> - public string PasteID { get; set; } + public string? PasteID { get; } /// <summary>The viewer's detected OS, if known.</summary> - public Platform? DetectedPlatform { get; set; } + public Platform? DetectedPlatform { get; } /// <summary>The parsed log info.</summary> - public ParsedLog ParsedLog { get; set; } + public ParsedLog? ParsedLog { get; private set; } /// <summary>Whether to show the raw unparsed log.</summary> - public bool ShowRaw { get; set; } + public bool ShowRaw { get; private set; } /// <summary>A non-blocking warning while uploading the log.</summary> - public string UploadWarning { get; set; } + public string? UploadWarning { get; set; } /// <summary>An error which occurred while uploading the log.</summary> - public string UploadError { get; set; } + public string? UploadError { get; set; } /// <summary>An error which occurred while parsing the log file.</summary> - public string ParseError => this.ParsedLog?.Error; + public string? ParseError => this.ParsedLog?.Error; /// <summary>When the uploaded file will no longer be available.</summary> - public DateTime? Expiry { get; set; } + public DateTimeOffset? Expiry { get; set; } + + /// <summary>Whether parsed log data is available.</summary> + [MemberNotNullWhen(true, nameof(LogParserModel.PasteID), nameof(LogParserModel.ParsedLog))] + public bool HasLog => this.ParsedLog != null; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public LogParserModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="pasteID">The paste ID.</param> /// <param name="platform">The viewer's detected OS, if known.</param> - public LogParserModel(string pasteID, Platform? platform) + public LogParserModel(string? pasteID, Platform? platform) { this.PasteID = pasteID; this.DetectedPlatform = platform; @@ -62,6 +65,26 @@ namespace StardewModdingAPI.Web.ViewModels this.ShowRaw = false; } + /// <summary>Construct an instance.</summary> + /// <param name="pasteId">The paste ID.</param> + /// <param name="detectedPlatform">The viewer's detected OS, if known.</param> + /// <param name="parsedLog">The parsed log info.</param> + /// <param name="showRaw">Whether to show the raw unparsed log.</param> + /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> + /// <param name="uploadError">An error which occurred while uploading the log.</param> + /// <param name="expiry">When the uploaded file will no longer be available.</param> + [JsonConstructor] + public LogParserModel(string pasteId, Platform? detectedPlatform, ParsedLog? parsedLog, bool showRaw, string? uploadWarning, string? uploadError, DateTime? expiry) + { + this.PasteID = pasteId; + this.DetectedPlatform = detectedPlatform; + this.ParsedLog = parsedLog; + this.ShowRaw = showRaw; + this.UploadWarning = uploadWarning; + this.UploadError = uploadError; + this.Expiry = expiry; + } + /// <summary>Set the log parser result.</summary> /// <param name="parsedLog">The parsed log info.</param> /// <param name="showRaw">Whether to show the raw unparsed log.</param> @@ -77,14 +100,14 @@ namespace StardewModdingAPI.Web.ViewModels public IDictionary<string, LogModInfo[]> GetContentPacksByMod() { // get all mods & content packs - LogModInfo[] mods = this.ParsedLog?.Mods; + LogModInfo[]? mods = this.ParsedLog?.Mods; if (mods == null || !mods.Any()) return new Dictionary<string, LogModInfo[]>(); // group by mod return mods - .Where(mod => mod.ContentPackFor != null) - .GroupBy(mod => mod.ContentPackFor) + .Where(mod => mod.IsContentPack) + .GroupBy(mod => mod.ContentPackFor!) .ToDictionary(group => group.Key, group => group.ToArray()); } diff --git a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs index 85bf1e46..36ea891d 100644 --- a/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs +++ b/src/SMAPI.Web/ViewModels/ModCompatibilityModel.cs @@ -1,3 +1,4 @@ +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.ViewModels @@ -9,22 +10,36 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The compatibility status, as a string like <c>"Broken"</c>.</summary> - public string Status { get; set; } + public string Status { get; } /// <summary>The human-readable summary, as an HTML block.</summary> - public string Summary { get; set; } + public string? Summary { get; } /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> - public string BrokeIn { get; set; } + public string? BrokeIn { get; } /// <summary>A link to the unofficial version which fixes compatibility, if any.</summary> - public ModLinkModel UnofficialVersion { get; set; } + public ModLinkModel? UnofficialVersion { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="status">The compatibility status, as a string like <c>"Broken"</c>.</param> + /// <param name="summary">The human-readable summary, as an HTML block.</param> + /// <param name="brokeIn">The game or SMAPI version which broke this mod (if applicable).</param> + /// <param name="unofficialVersion">A link to the unofficial version which fixes compatibility, if any.</param> + [JsonConstructor] + public ModCompatibilityModel(string status, string? summary, string? brokeIn, ModLinkModel? unofficialVersion) + { + this.Status = status; + this.Summary = summary; + this.BrokeIn = brokeIn; + this.UnofficialVersion = unofficialVersion; + } + + /// <summary>Construct an instance.</summary> /// <param name="info">The mod metadata.</param> public ModCompatibilityModel(WikiCompatibilityInfo info) { @@ -34,7 +49,7 @@ namespace StardewModdingAPI.Web.ViewModels this.Summary = info.Summary; this.BrokeIn = info.BrokeIn; if (info.UnofficialVersion != null) - this.UnofficialVersion = new ModLinkModel(info.UnofficialUrl, info.UnofficialVersion.ToString()); + this.UnofficialVersion = new ModLinkModel(info.UnofficialUrl!, info.UnofficialVersion.ToString()); } } } diff --git a/src/SMAPI.Web/ViewModels/ModLinkModel.cs b/src/SMAPI.Web/ViewModels/ModLinkModel.cs index 97dd215c..96f14d48 100644 --- a/src/SMAPI.Web/ViewModels/ModLinkModel.cs +++ b/src/SMAPI.Web/ViewModels/ModLinkModel.cs @@ -7,10 +7,10 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The URL of the linked page.</summary> - public string Url { get; set; } + public string Url { get; } /// <summary>The suggested link text.</summary> - public string Text { get; set; } + public string Text { get; } /********* diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index 6b8279c1..be9f973a 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -11,37 +11,34 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The current stable version of the game.</summary> - public string StableVersion { get; set; } + public string? StableVersion { get; } /// <summary>The current beta version of the game (if any).</summary> - public string BetaVersion { get; set; } + public string? BetaVersion { get; } /// <summary>The mods to display.</summary> - public ModModel[] Mods { get; set; } + public ModModel[] Mods { get; } /// <summary>When the data was last updated.</summary> - public DateTimeOffset LastUpdated { get; set; } + public DateTimeOffset LastUpdated { get; } /// <summary>Whether the data hasn't been updated in a while.</summary> - public bool IsStale { get; set; } + public bool IsStale { get; } /// <summary>Whether the mod metadata is available.</summary> - public bool HasData => this.Mods?.Any() == true; + public bool HasData => this.Mods.Any(); /********* ** Public methods *********/ - /// <summary>Construct an empty instance.</summary> - public ModListModel() { } - /// <summary>Construct an instance.</summary> /// <param name="stableVersion">The current stable version of the game.</param> /// <param name="betaVersion">The current beta version of the game (if any).</param> /// <param name="mods">The mods to display.</param> /// <param name="lastUpdated">When the data was last updated.</param> /// <param name="isStale">Whether the data hasn't been updated in a while.</param> - public ModListModel(string stableVersion, string betaVersion, IEnumerable<ModModel> mods, DateTimeOffset lastUpdated, bool isStale) + public ModListModel(string? stableVersion, string? betaVersion, IEnumerable<ModModel> mods, DateTimeOffset lastUpdated, bool isStale) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 575d596a..929bf682 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; namespace StardewModdingAPI.Web.ViewModels @@ -11,43 +12,43 @@ namespace StardewModdingAPI.Web.ViewModels ** Accessors *********/ /// <summary>The mod name.</summary> - public string Name { get; set; } + public string? Name { get; } /// <summary>The mod's alternative names, if any.</summary> - public string AlternateNames { get; set; } + public string AlternateNames { get; } /// <summary>The mod author's name.</summary> - public string Author { get; set; } + public string? Author { get; } /// <summary>The mod author's alternative names, if any.</summary> - public string AlternateAuthors { get; set; } + public string AlternateAuthors { get; } /// <summary>The GitHub repo, if any.</summary> - public string GitHubRepo { get; set; } + public string? GitHubRepo { get; } /// <summary>The URL to the mod's source code, if any.</summary> - public string SourceUrl { get; set; } + public string? SourceUrl { get; } /// <summary>The compatibility status for the stable version of the game.</summary> - public ModCompatibilityModel Compatibility { get; set; } + public ModCompatibilityModel Compatibility { get; } /// <summary>The compatibility status for the beta version of the game.</summary> - public ModCompatibilityModel BetaCompatibility { get; set; } + public ModCompatibilityModel? BetaCompatibility { get; } /// <summary>Links to the available mod pages.</summary> - public ModLinkModel[] ModPages { get; set; } + public ModLinkModel[] ModPages { get; } /// <summary>The human-readable warnings for players about this mod.</summary> - public string[] Warnings { get; set; } + public string[] Warnings { get; } /// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary> - public string PullRequestUrl { get; set; } + public string? PullRequestUrl { get; } - /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> - public string DevNote { get; set; } + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests.</summary> + public string? DevNote { get; } /// <summary>A unique identifier for the mod that can be used in an anchor URL.</summary> - public string Slug { get; set; } + public string? Slug { get; } /// <summary>The sites where the mod can be downloaded.</summary> public string[] ModPageSites => this.ModPages.Select(p => p.Text).ToArray(); @@ -57,6 +58,38 @@ namespace StardewModdingAPI.Web.ViewModels ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <param name="name">The mod name.</param> + /// <param name="alternateNames">The mod's alternative names, if any.</param> + /// <param name="author">The mod author's name.</param> + /// <param name="alternateAuthors">The mod author's alternative names, if any.</param> + /// <param name="gitHubRepo">The GitHub repo, if any.</param> + /// <param name="sourceUrl">The URL to the mod's source code, if any.</param> + /// <param name="compatibility">The compatibility status for the stable version of the game.</param> + /// <param name="betaCompatibility">The compatibility status for the beta version of the game.</param> + /// <param name="modPages">Links to the available mod pages.</param> + /// <param name="warnings">The human-readable warnings for players about this mod.</param> + /// <param name="pullRequestUrl">The URL of the pull request which submits changes for an unofficial update to the author, if any.</param> + /// <param name="devNote">Special notes intended for developers who maintain unofficial updates or submit pull requests.</param> + /// <param name="slug">A unique identifier for the mod that can be used in an anchor URL.</param> + [JsonConstructor] + public ModModel(string? name, string alternateNames, string author, string alternateAuthors, string gitHubRepo, string sourceUrl, ModCompatibilityModel compatibility, ModCompatibilityModel betaCompatibility, ModLinkModel[] modPages, string[] warnings, string pullRequestUrl, string devNote, string slug) + { + this.Name = name; + this.AlternateNames = alternateNames; + this.Author = author; + this.AlternateAuthors = alternateAuthors; + this.GitHubRepo = gitHubRepo; + this.SourceUrl = sourceUrl; + this.Compatibility = compatibility; + this.BetaCompatibility = betaCompatibility; + this.ModPages = modPages; + this.Warnings = warnings; + this.PullRequestUrl = pullRequestUrl; + this.DevNote = devNote; + this.Slug = slug; + } + + /// <summary>Construct an instance.</summary> /// <param name="entry">The mod metadata.</param> public ModModel(WikiModEntry entry) { @@ -82,7 +115,7 @@ namespace StardewModdingAPI.Web.ViewModels *********/ /// <summary>Get the web URL for the mod's source code repository, if any.</summary> /// <param name="entry">The mod metadata.</param> - private string GetSourceUrl(WikiModEntry entry) + private string? GetSourceUrl(WikiModEntry entry) { if (!string.IsNullOrWhiteSpace(entry.GitHubRepo)) return $"https://github.com/{entry.GitHubRepo}"; diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 669cfd99..acb8df78 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -24,7 +24,7 @@ <div id="call-to-action"> <div class="cta-dropdown"> - <a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br /> + <a href="@Model!.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br /> <div class="dropdown-content"> <a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a> <a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a> diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index 1db79857..f5ec0f7a 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -5,10 +5,10 @@ @{ // get view data - string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID }, absoluteUrl: true); - string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName }); - string schemaDisplayName = null; - bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none"; + string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model!.SchemaName, id = Model.PasteID }, absoluteUrl: true)!; + string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName })!; + string? schemaDisplayName = null; + bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName.ToLower() != "none"; // build title ViewData["Title"] = "JSON validator"; diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 91fc3535..5e55906d 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -7,15 +7,25 @@ @{ ViewData["Title"] = "SMAPI log parser"; + + ParsedLog? log = Model!.ParsedLog; + IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod(); IDictionary<string, bool> defaultFilters = Enum - .GetValues(typeof(LogLevel)) - .Cast<LogLevel>() + .GetValues<LogLevel>() .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); - string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true); + IDictionary<int, string> logLevels = Enum + .GetValues<LogLevel>() + .ToDictionary(level => (int)level, level => level.ToString().ToLower()); + + IDictionary<int, string> logSections = Enum + .GetValues<LogSection>() + .ToDictionary(section => (int)section, section => section.ToString()); - ISet<int> screenIds = new HashSet<int>(Model.ParsedLog?.Messages?.Select(p => p.ScreenId) ?? new int[0]); + string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true)!; + + ISet<int> screenIds = new HashSet<int>(log?.Messages.Select(p => p.ScreenId) ?? Array.Empty<int>()); } @section Head { @@ -24,31 +34,81 @@ <meta name="robots" content="noindex" /> } <link rel="stylesheet" href="~/Content/css/file-upload.css" /> - <link rel="stylesheet" href="~/Content/css/log-parser.css" /> + <link rel="stylesheet" href="~/Content/css/log-parser.css?r=20220409" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3/dist/css/tabby-ui-vertical.min.css" /> <script src="https://cdn.jsdelivr.net/npm/tabbyjs@12.0.3" crossorigin="anonymous"></script> - <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1" crossorigin="anonymous"></script> <script src="~/Content/js/file-upload.js"></script> - <script src="~/Content/js/log-parser.js"></script> + <script src="~/Content/js/log-parser.js?r=20220409"></script> + + <script id="serializedData" type="application/json"> + @if (!Model.ShowRaw) + { + <text> + { + "messages": @this.ForJson(log?.Messages), + "sections": @this.ForJson(logSections), + "logLevels": @this.ForJson(logLevels), + "modSlugs": @this.ForJson(log?.Mods.DistinctBy(p => p.Name).Select(p => new {p.Name, Slug = Model.GetSlug(p.Name)}).Where(p => p.Name != p.Slug).ToDictionary(p => p.Name, p => p.Slug)), + "screenIds": @this.ForJson(screenIds) + } + </text> + } + else + { + <text> + { + "messages": [], + "sections": {}, + "logLevels": {}, + "modSlugs": {}, + "screenIds": [] + } + </text> + } + </script> + <script> $(function() { - smapi.logParser({ - logStarted: new Date(@this.ForJson(Model.ParsedLog?.Timestamp)), - showPopup: @this.ForJson(Model.ParsedLog == null), - showMods: @this.ForJson(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true)), - showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, section => false)), - showLevels: @this.ForJson(defaultFilters), - enableFilters: @this.ForJson(!Model.ShowRaw), - screenIds: @this.ForJson(screenIds) - }, '@this.Url.PlainAction("Index", "LogParser", values: null)'); + smapi.logParser( + { + logStarted: new Date(@this.ForJson(log?.Timestamp)), + dataElement: "script#serializedData", + showPopup: @this.ForJson(log == null), + showMods: @this.ForJson(log?.Mods.Where(p => p.Loaded && !p.IsContentPack).Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, _ => true)), + showSections: @this.ForJson(Enum.GetNames(typeof(LogSection)).ToDictionary(section => section, _ => false)), + showLevels: @this.ForJson(defaultFilters), + enableFilters: @this.ForJson(!Model.ShowRaw) + } + ); - new Tabby('[data-tabs]'); + @if (log == null) + { + <text> + new Tabby("[data-tabs]"); + </text> + } }); </script> } +@* quick navigation links *@ +@section SidebarExtra { + @if (log != null) + { + <nav id="quickNav"> + <h4>Scroll to...</h4> + <ul> + <li><a href="#content">Top</a></li> + <li><a href="#filterHolder">Log start</a></li> + <li><a href="#footer">Bottom</a></li> + </ul> + </nav> + } +} + @* upload result banner *@ @if (Model.UploadError != null) { @@ -67,7 +127,7 @@ else if (Model.ParseError != null) <small v-pre>Error details: @Model.ParseError</small> </div> } -else if (Model.ParsedLog?.IsValid == true) +else if (log?.IsValid == true) { <div class="banner success" v-pre> <strong>Share this link to let someone else see the log:</strong> <code>@curPageUrl</code><br /> @@ -92,7 +152,7 @@ else if (Model.ParsedLog?.IsValid == true) } @* upload new log *@ -@if (Model.ParsedLog == null) +@if (log == null) { <h2>Where do I find my SMAPI log?</h2> <div id="os-instructions"> @@ -157,7 +217,7 @@ else if (Model.ParsedLog?.IsValid == true) </div> <h2>How do I share my log?</h2> - <form action="@this.Url.PlainAction("PostAsync", "LogParser")" method="post"> + <form action="@this.Url.PlainAction("Post", "LogParser")" method="post"> <input id="inputFile" type="file" /> <ol> <li> @@ -174,10 +234,10 @@ else if (Model.ParsedLog?.IsValid == true) } @* parsed log *@ -@if (Model.ParsedLog?.IsValid == true) +@if (log?.IsValid == true) { <div id="output"> - @if (Model.ParsedLog.Mods.Any(mod => mod.HasUpdate)) + @if (log.Mods.Any(mod => mod.HasUpdate)) { <h2>Suggested fixes</h2> <ul id="fix-list"> @@ -185,12 +245,12 @@ else if (Model.ParsedLog?.IsValid == true) Consider updating these mods to fix problems: <table id="updates" class="table"> - @foreach (LogModInfo mod in Model.ParsedLog.Mods.Where(mod => (mod.HasUpdate && mod.ContentPackFor == null) || (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) + @foreach (LogModInfo mod in log.Mods.Where(mod => (mod.HasUpdate && !mod.IsContentPack) || (contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList) && contentPackList.Any(pack => pack.HasUpdate)))) { <tr class="mod-entry"> <td> <strong class=@(!mod.HasUpdate ? "hidden" : "")>@mod.Name</strong> - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) { <div class="content-packs"> @foreach (LogModInfo contentPack in contentPackList.Where(pack => pack.HasUpdate)) @@ -204,7 +264,7 @@ else if (Model.ParsedLog?.IsValid == true) @if (mod.HasUpdate) { <a href="@mod.UpdateLink" target="_blank"> - @(mod.Version == null ? @mod.UpdateVersion : $"{mod.Version} → {mod.UpdateVersion}") + @(mod.Version == null ? mod.UpdateVersion : $"{mod.Version} → {mod.UpdateVersion}") </a> } else @@ -230,23 +290,33 @@ else if (Model.ParsedLog?.IsValid == true) } <h2>Log info</h2> - <table id="metadata" class="table"> + <table + id="metadata" + class="table" + data-code-mods="@log.Mods.Count(p => !p.IsContentPack)" + data-content-packs="@log.Mods.Count(p => p.IsContentPack)" + data-os="@log.OperatingSystem" + data-game-version="@log.GameVersion" + data-game-path="@log.GamePath" + data-smapi-version="@log.ApiVersion" + data-log-started="@log.Timestamp.UtcDateTime.ToString("O")" + > <caption>Game info:</caption> <tr> <th>Stardew Valley:</th> - <td v-pre>@Model.ParsedLog.GameVersion on @Model.ParsedLog.OperatingSystem</td> + <td v-pre>@log.GameVersion on @log.OperatingSystem</td> </tr> <tr> <th>SMAPI:</th> - <td v-pre>@Model.ParsedLog.ApiVersion</td> + <td v-pre>@log.ApiVersion</td> </tr> <tr> <th>Folder:</th> - <td v-pre>@Model.ParsedLog.GamePath</td> + <td v-pre>@log.GamePath</td> </tr> <tr> <th>Log started:</th> - <td>@Model.ParsedLog.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td> + <td>@log.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td> </tr> </table> <br /> @@ -258,29 +328,34 @@ else if (Model.ParsedLog?.IsValid == true) <span class="notice txt"><i>click any mod to filter</i></span> <span class="notice btn txt" v-on:click="showAllMods" v-bind:class="{ invisible: !anyModsHidden }">show all</span> <span class="notice btn txt" v-on:click="hideAllMods" v-bind:class="{ invisible: !anyModsShown || !anyModsHidden }">hide all</span> + <span class="notice btn txt" v-on:click="toggleContentPacks">toggle content packs in list</span> } </caption> - @foreach (var mod in Model.ParsedLog.Mods.Where(p => p.Loaded && p.ContentPackFor == null)) + @foreach (var mod in log.Mods.Where(p => p.Loaded && !p.IsContentPack)) { + if (contentPacks == null || !contentPacks.TryGetValue(mod.Name, out LogModInfo[]? contentPackList)) + contentPackList = null; + <tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }"> <td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-bind:class="{ invisible: !anyModsHidden }" /></td> - <td v-pre> - <strong>@mod.Name</strong> @mod.Version - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) + <td> + <strong v-pre>@mod.Name</strong> @mod.Version + @if (contentPackList != null) { - <div class="content-packs"> + <div v-if="!hideContentPacks" class="content-packs"> @foreach (var contentPack in contentPackList) { <text>+ @contentPack.Name @contentPack.Version</text><br /> } </div> + <span v-else class="content-packs-collapsed"> (+ @contentPackList.Length content packs)</span> } </td> - <td v-pre> + <td> @mod.Author - @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out contentPackList)) + @if (contentPackList != null) { - <div class="content-packs"> + <div v-if="!hideContentPacks" class="content-packs"> @foreach (var contentPack in contentPackList) { <text>+ @contentPack.Author</text><br /> @@ -306,61 +381,75 @@ else if (Model.ParsedLog?.IsValid == true) @if (!Model.ShowRaw) { + <div id="filterHolder"></div> <div id="filters"> - Filter messages: - <span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> | - <span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> | - <span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> | - <span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> | - <span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> | - <span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span> + <div class="toggles"> + <div> + Filter messages: + </div> + <div> + <span role="button" v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> | + <span role="button" v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> | + <span role="button" v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> | + <span role="button" v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> | + <span role="button" v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> | + <span role="button" v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span> + <div class="filter-text"> + <input + type="text" + v-bind:class="{ active: !!filterText }" + v-model="filterText" + v-on:input="updateFilterText" + placeholder="search to filter log..." + /> + <span role="button" v-bind:class="{ active: filterUseRegex }" v-on:click="toggleFilterUseRegex" title="Use regular expression syntax.">.*</span> + <span role="button" v-bind:class="{ active: !filterInsensitive }" v-on:click="toggleFilterInsensitive" title="Match exact capitalization only.">aA</span> + <span role="button" v-bind:class="{ active: filterUseWord, 'whole-word': true }" v-on:click="toggleFilterWord" title="Match whole word only."><i>“ ”</i></span> + <span role="button" v-bind:class="{ active: shouldHighlight }" v-on:click="toggleHighlight" title="Highlight matches in the log text.">HL</span> + <div + v-if="filterError" + class="filter-error" + > + {{ filterError }} + </div> + </div> + <filter-stats + v-bind:start="start" + v-bind:end="end" + v-bind:pages="totalPages" + v-bind:filtered="filteredMessages.total" + v-bind:total="totalMessages" + /> + </div> + </div> + <pager + v-bind:page="page" + v-bind:pages="totalPages" + v-bind:prevPage="prevPage" + v-bind:nextPage="nextPage" + /> </div> - <table id="log"> - @foreach (var message in Model.ParsedLog.Messages) - { - string levelStr = message.Level.ToString().ToLower(); - string sectionStartClass = message.IsStartOfSection ? "section-start" : null; - string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable + <noscript> + <div> + This website uses JavaScript to display a filterable table. To view this log, please enable JavaScript or <a href="@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID, format = LogViewFormat.RawView })">view the raw log</a>. + </div> + <br/> + </noscript> - <tr class="mod @levelStr @sectionStartClass" - @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> } - v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - <td v-pre>@message.Time</td> - @if (screenIds.Count > 1) - { - <td v-pre>screen_@message.ScreenId</td> - } - <td v-pre>@message.Level.ToString().ToUpper()</td> - <td v-pre data-title="@message.Mod">@message.Mod</td> - <td> - <span v-pre class="log-message-text">@message.Text</span> - @if (message.IsStartOfSection) - { - <span class="section-toggle-message"> - <template v-if="sectionsAllow('@message.Section')"> - This section is shown. Click here to hide it. - </template> - <template v-else> - This section is hidden. Click here to show it. - </template> - </span> - } - </td> - </tr> - if (message.Repeated > 0) - { - <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> - <td colspan="4"></td> - <td v-pre><i>repeats [@message.Repeated] times.</i></td> - </tr> - } - } - </table> + <log-table> + <log-line + v-for="msg in visibleMessages" + v-bind:key="msg.id" + v-bind:showScreenId="showScreenId" + v-bind:message="msg" + v-bind:highlight="shouldHighlight" + /> + </log-table> } else { - <pre v-pre>@Model.ParsedLog.RawText</pre> + <pre v-pre>@log.RawText</pre> } <small> @@ -377,8 +466,8 @@ else if (Model.ParsedLog?.IsValid == true) </small> </div> } -else if (Model.ParsedLog?.IsValid == false) +else if (log?.IsValid == false) { <h3>Raw log</h3> - <pre v-pre>@Model.ParsedLog.RawText</pre> + <pre v-pre>@log.RawText</pre> } diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 8a764803..78eabad7 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -6,7 +6,7 @@ @{ ViewData["Title"] = "Mod compatibility"; - TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; + TimeSpan staleAge = DateTimeOffset.UtcNow - Model!.LastUpdated; bool hasBeta = Model.BetaVersion != null; string betaLabel = $"SDV {Model.BetaVersion} only"; @@ -19,7 +19,7 @@ <script src="~/Content/js/mods.js?r=20210929"></script> <script> $(function() { - var data = @this.ForJson(Model.Mods ?? new ModModel[0]); + var data = @(this.ForJson(Model.Mods ?? Array.Empty<ModModel>())); var enableBeta = @this.ForJson(hasBeta); smapi.modList(data, enableBeta); }); diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 67dcd3b3..1e82ab5f 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -26,6 +26,8 @@ <li><a href="@Url.PlainAction("Index", "LogParser", values: null)">Log parser</a></li> <li><a href="@Url.PlainAction("Index", "JsonValidator", values: null)">JSON validator</a></li> </ul> + + @RenderSection("SidebarExtra", required: false) </div> <div id="content-column"> <div id="content"> diff --git a/src/SMAPI.Web/Views/_ViewStart.cshtml b/src/SMAPI.Web/Views/_ViewStart.cshtml index a5f10045..820a2f6e 100644 --- a/src/SMAPI.Web/Views/_ViewStart.cshtml +++ b/src/SMAPI.Web/Views/_ViewStart.cshtml @@ -1,3 +1,3 @@ -@{ +@{ Layout = "_Layout"; } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 0265a928..1231f824 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -17,7 +17,8 @@ "Site": { "BetaEnabled": false, - "OtherBlurb": null + "OtherBlurb": null, + "SupporterList": null }, "ApiClients": { diff --git a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css index ff170691..f29d46aa 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/file-upload.css +++ b/src/SMAPI.Web/wwwroot/Content/css/file-upload.css @@ -11,7 +11,7 @@ border-radius: 5px; border: 1px solid #000088; outline: none; - box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); + box-shadow: inset 0 0 1px 1px rgba(0, 0, 192, .2); } #submit { diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css index 8c3acceb..1d457e35 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css +++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css @@ -113,7 +113,7 @@ table caption { } .table tr { - background: #eee + background: #eee; } #mods span.notice { @@ -148,18 +148,79 @@ table caption { font-style: italic; } +.table .content-packs-collapsed { + opacity: 0.75; + font-size: 0.9em; + font-style: italic; +} + #metadata td:first-child { padding-right: 5px; } .table tr:nth-child(even) { - background: #fff + background: #fff; } #filters { margin: 1em 0 0 0; - padding: 0; + padding: 0 0 0.5em; + display: flex; + justify-content: space-between; + width: calc(100vw - 16em); +} + +#filters > div { + align-self: center; +} + +#filters .toggles { + display: flex; +} + +#filters .toggles > div:first-child { font-weight: bold; + padding: 0.2em 1em 0 0; +} + +#filters .filter-text { + margin-top: 0.5em; +} + +#filters .filter-error { + color: #880000; +} + +#filters .filter-error, +#filters .stats { + margin-top: 0.5em; + font-size: 0.75em; +} + +#filters.sticky { + position: fixed; + top: 0; + left: 0em; + background: #fff; + margin: 0; + padding: 0.5em; + width: calc(100% - 1em); +} + +@media (min-width: 1020px) and (max-width: 1199px) { + #filters:not(.sticky) { + width: calc(100vw - 13em); + } +} + +@media (max-width: 1019px) { + #filters:not(.sticky) { + width: calc(100vw - 5em); + } + + #filters { + display: block; + } } #filters span { @@ -173,6 +234,17 @@ table caption { color: #000; border-color: #880000; background-color: #fcc; + + user-select: none; +} + +#filters .filter-text span { + padding: 3px 0.5em; +} + +#filters .whole-word i { + padding: 0 1px; + border: 1px dashed; } #filters span:hover { @@ -188,11 +260,48 @@ table caption { background: #efe; } +#filters .pager { + margin-top: 0.5em; + text-align: right; +} + +#filters .pager div { + margin-top: 0.5em; +} + +#filters .pager div span { + padding: 0 0.5em; + margin: 0 1px; +} + +#filters .pager span { + background-color: #eee; + border-color: #888; +} + +#filters .pager span.active { + font-weight: bold; + border-color: transparent; + background: transparent; + cursor: unset; +} + +#filters .pager span.disabled { + opacity: 0.3; + cursor: unset; +} + +#filters .pager span:not(.disabled):hover { + background-color: #fff; +} + + /********* ** Log *********/ #log .mod-repeat { font-size: 0.85em; + font-style: italic; } #log .trace { @@ -237,6 +346,11 @@ table caption { white-space: pre-wrap; } +#log .log-message-text strong { + background-color: yellow; + font-weight: normal; +} + #log { border-spacing: 0; } diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index dcc7a798..a0a407d8 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -97,6 +97,22 @@ a { margin-left: 1em; } +/* quick navigation */ + +#quickNav { + position: fixed; + bottom: 3em; + width: 12em; +} + +@media (max-height: 400px) { + #quickNav { + position: unset; + width: auto; + } +} + + /* footer */ #footer { margin: 1em; @@ -111,11 +127,16 @@ a { /* mobile fixes */ @media (min-width: 1020px) and (max-width: 1199px) { + #quickNav, #sidebar { width: 7em; background: none; } + #quickNav h4 { + width: unset; + } + #content-column { left: 7em; } @@ -138,4 +159,9 @@ a { top: inherit; left: inherit; } + + #quickNav { + position: unset; + width: auto; + } } diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index 90715375..fccd00be 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -1,56 +1,922 @@ -/* globals $ */ +/* globals $, Vue */ +/** + * The global SMAPI module. + */ var smapi = smapi || {}; + +/** + * The Vue app for the current page. + * @type {Vue} + */ var app; -smapi.logParser = function (data, sectionUrl) { + +// Use a scroll event to apply a sticky effect to the filters / pagination +// bar. We can't just use "position: sticky" due to how the page is structured +// but this works well enough. +$(function () { + let sticking = false; + + document.addEventListener("scroll", function () { + const filters = document.getElementById("filters"); + const holder = document.getElementById("filterHolder"); + if (!filters || !holder) + return; + + const offset = holder.offsetTop; + const shouldStick = window.pageYOffset > offset; + if (shouldStick === sticking) + return; + + sticking = shouldStick; + if (sticking) { + holder.style.marginBottom = `calc(1em + ${filters.offsetHeight}px)`; + filters.classList.add("sticky"); + } + else { + filters.classList.remove("sticky"); + holder.style.marginBottom = ""; + } + }); +}); + +/** + * Initialize a log parser view on the current page. + * @param {object} state The state options to use. + * @returns {void} + */ +smapi.logParser = function (state) { + if (!state) + state = {}; + + // internal helpers + const helpers = { + /** + * Get a handler which invokes the callback after a set delay, resetting the delay each time it's called. + * @param {(...*) => void} action The callback to invoke when the delay ends. + * @param {number} delay The number of milliseconds to delay the action after each call. + * @returns {() => void} + */ + getDebouncedHandler(action, delay) { + let timeoutId = null; + + return function () { + clearTimeout(timeoutId); + + const args = arguments; + const self = this; + + timeoutId = setTimeout( + function () { + action.apply(self, args); + }, + delay + ); + } + }, + + /** + * Escape regex special characters in the given string. + * @param {string} text + * @returns {string} + */ + escapeRegex(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + }, + + /** + * Format a number for the user's locale. + * @param {number} value The number to format. + * @returns {string} + */ + formatNumber(value) { + const formatter = window.Intl && Intl.NumberFormat && new Intl.NumberFormat(); + return formatter && formatter.format + ? formatter.format(value) + : `${value}`; + }, + + /** + * Try parsing the value as a base-10 integer. + * @param {string} value The value to parse. + * @param {number} defaultValue The value to return if parsing fails. + * @param {() => boolean} criteria An optional callback to check whether a parsed number is valid. + * @returns {number} The parsed number if it's valid, else the default value. + */ + tryParseNumber(value, defaultValue, criteria = null) { + value = parseInt(value, 10); + return !isNaN(value) && isFinite(value) && (!criteria || criteria(value)) + ? value + : defaultValue; + }, + + /** + * Get whether two objects are equivalent based on their top-level properties. + * @param {Object} left The first value to compare. + * @param {Object} right The second value to compare. + * @returns {Boolean} + */ + shallowEquals(left, right) { + if (typeof left !== "object" || typeof right !== "object") + return left === right; + + if (left == null || right == null) + return left == null && right == null; + + if (Array.isArray(left) !== Array.isArray(right)) + return false; + + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + + if (leftKeys.length != rightKeys.length) + return false; + + for (const key of leftKeys) { + if (!rightKeys.includes(key) || left[key] !== right[key]) + return false; + } + + return true; + } + }; + + // internal event handlers + const handlers = { + /** + * Method called when the user clicks a log line to toggle the visibility of a section. Binding methods is problematic with functional components so we just use the `data-section` parameter and our global reference to the app. + * @param {any} event + * @returns {false} + */ + clickLogLine(event) { + app.toggleSection(event.currentTarget.dataset.section); + event.preventDefault(); + return false; + }, + + /** + * Navigate to the previous page of messages in the log. + * @returns {void} + */ + prevPage() { + app.prevPage(); + }, + + /** + * Navigate to the next page of messages in the log. + * @returns {void} + */ + nextPage() { + app.nextPage(); + }, + + /** + * Handle a click on a page number element. + * @param {number | Event} event + * @returns {void} + */ + changePage(event) { + if (typeof event === "number") + app.changePage(event); + else if (event) { + const page = parseInt(event.currentTarget.dataset.page); + if (!isNaN(page) && isFinite(page)) + app.changePage(page); + } + } + }; + // internal filter counts - var stats = data.stats = { + const stats = state.stats = { modsShown: 0, modsHidden: 0 }; - function updateModFilters() { - // counts - stats.modsShown = 0; - stats.modsHidden = 0; - for (var key in data.showMods) { - if (data.showMods.hasOwnProperty(key)) { - if (data.showMods[key]) - stats.modsShown++; - else - stats.modsHidden++; + + // load raw log data + { + const dataElement = document.querySelector(state.dataElement); + state.data = JSON.parse(dataElement.textContent.trim()); + dataElement.remove(); // let browser unload the data element since we won't need it anymore + } + + // preprocess data for display + state.messages = state.data.messages || []; + if (state.messages.length) { + const levels = state.data.logLevels; + const sections = state.data.sections; + const modSlugs = state.data.modSlugs; + + for (let i = 0, length = state.messages.length; i < length; i++) { + const message = state.messages[i]; + + // add unique ID + message.id = i; + + // add display values + message.LevelName = levels[message.Level]; + message.SectionName = sections[message.Section]; + message.ModSlug = modSlugs[message.Mod] || message.Mod; + + // For repeated messages, since our <log-line /> component + // can't return two rows, just insert a second message + // which will display as the message repeated notice. + if (message.Repeated > 0 && !message.isRepeated) { + const repeatNote = { + id: i + 1, + Level: message.Level, + Section: message.Section, + Mod: message.Mod, + Repeated: message.Repeated, + isRepeated: true + }; + + state.messages.splice(i + 1, 0, repeatNote); + length++; } + + // let Vue know the message won't change, so it doesn't need to monitor it + Object.freeze(message); } } + Object.freeze(state.messages); // set local time started - if (data) - data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); + if (state.logStarted) + state.localTimeStarted = ("0" + state.logStarted.getHours()).slice(-2) + ":" + ("0" + state.logStarted.getMinutes()).slice(-2); + + // add the properties we're passing to Vue + const defaultPerPage = 1000; + state.totalMessages = state.messages.length; + state.filterText = ""; + state.filterRegex = null; + state.filterError = null; + state.showContentPacks = true; + state.useHighlight = true; + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; + state.perPage = defaultPerPage; + state.page = 1; + + state.defaultMods = { ...state.showMods }; + state.defaultSections = { ...state.showSections }; + state.defaultLevels = { ...state.showLevels }; + + // load saved values, if any + if (localStorage.settings) { + try { + const saved = JSON.parse(localStorage.settings); + + state.showContentPacks = saved.showContentPacks ?? state.showContentPacks; + state.useHighlight = saved.useHighlight ?? state.useHighlight; + state.useRegex = saved.useRegex ?? state.useRegex; + state.useInsensitive = saved.useInsensitive ?? state.useInsensitive; + state.useWord = saved.useWord ?? state.useWord; + } + catch (error) { + // ignore settings if invalid + } + } + + // add a number formatter so our numbers look nicer + Vue.filter("number", handlers.formatNumber); + + // Strictly speaking, we don't need this. However, due to the way our + // Vue template is living in-page the browser is "helpful" and moves + // our <log-line />s outside of a basic <table> since obviously they + // aren't table rows and don't belong inside a table. By using another + // Vue component, we avoid that. + Vue.component("log-table", { + functional: true, + render: function (createElement, context) { + return createElement( + "table", + { + attrs: { + id: "log" + } + }, + context.children + ); + } + }); + + // The <filter-stats /> component draws a nice message under the filters + // telling a user how many messages match their filters, and also expands + // on how many of them they're seeing because of pagination. + Vue.component("filter-stats", { + functional: true, + render: function (createElement, context) { + const props = context.props; + return createElement( + "div", + { class: "stats" }, + [ + createElement( + "abbr", + { + attrs: { + title: "These numbers may be inaccurate when using filtering with sections collapsed." + } + }, + [ + "showing ", + createElement("strong", helpers.formatNumber(props.start + 1)), + " to ", + createElement("strong", helpers.formatNumber(props.end)), + " of ", + createElement("strong", helpers.formatNumber(props.filtered)) + ] + ), + " (total: ", + createElement("strong", helpers.formatNumber(props.total)), + ")" + ] + ); + } + }); + + // Next up we have <pager /> which renders the pagination list. This has a + // helper method to make building the list of links easier. + function addPageLink(page, links, visited, createElement, currentPage) { + if (visited.has(page)) + return; + + if (page > 1 && !visited.has(page - 1)) + links.push(" … "); + + visited.add(page); + links.push(createElement( + "span", + { + class: page === currentPage ? "active" : null, + attrs: { + "data-page": page + }, + on: { + click: handlers.changePage + } + }, + helpers.formatNumber(page) + )); + } + + Vue.component("pager", { + functional: true, + render: function (createElement, context) { + const props = context.props; + if (props.pages <= 1) + return null; + + const visited = new Set(); + const pageLinks = []; + + for (let i = 1; i <= 2; i++) + addPageLink(i, pageLinks, visited, createElement, props.page); + + for (let i = props.page - 2; i <= props.page + 2; i++) { + if (i >= 1 && i <= props.pages) + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + for (let i = props.pages - 2; i <= props.pages; i++) { + if (i >= 1) + addPageLink(i, pageLinks, visited, createElement, props.page); + } + + return createElement( + "div", + { class: "pager" }, + [ + createElement( + "span", + { + class: props.page <= 1 ? "disabled" : null, + on: { + click: handlers.prevPage + } + }, + "Prev" + ), + " ", + "Page ", + helpers.formatNumber(props.page), + " of ", + helpers.formatNumber(props.pages), + " ", + createElement( + "span", + { + class: props.page >= props.pages ? "disabled" : null, + on: { + click: handlers.nextPage + } + }, + "Next" + ), + createElement("div", {}, pageLinks) + ] + ); + } + }); + + // Our <log-line /> functional component draws each log line. + Vue.component("log-line", { + functional: true, + props: { + showScreenId: { + type: Boolean, + required: true + }, + message: { + type: Object, + required: true + }, + highlight: { + type: Boolean, + required: false + } + }, + render: function (createElement, context) { + const message = context.props.message; + const level = message.LevelName; + + if (message.isRepeated) + return createElement( + "tr", + { + class: [ + "mod", + level, + "mod-repeat" + ] + }, + [ + createElement( + "td", + { + attrs: { + colspan: context.props.showScreenId ? 4 : 3 + } + }, + "" + ), + createElement("td", `repeats ${message.Repeated} times`) + ] + ); + + const events = {}; + let toggleMessage; + if (message.IsStartOfSection) { + const visible = message.SectionName && window.app && app.sectionsAllow(message.SectionName); + events.click = handlers.clickLogLine; + toggleMessage = visible + ? "This section is shown. Click here to hide it." + : "This section is hidden. Click here to show it."; + } + + let text = message.Text; + const filter = window.app && app.filterRegex; + if (text && filter && context.props.highlight) { + text = []; + let match; + let consumed = 0; + let index = 0; + filter.lastIndex = -1; + + // Our logic to highlight the text is a bit funky because we + // want to group consecutive matches to avoid a situation + // where a ton of single characters are in their own elements + // if the user gives us bad input. + + while (true) { + match = filter.exec(message.Text); + if (!match) + break; + + // Do we have an area of non-matching text? This + // happens if the new match's index is further + // along than the last index. + if (match.index > index) { + // Alright, do we have a previous match? If + // we do, we need to consume some text. + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); + + text.push(message.Text.slice(index, match.index)); + consumed = match.index; + } + + index = match.index + match[0].length; + + // In the event of a zero-length match, forcibly increment + // the last index of the regular expression to ensure we + // aren't stuck in an infinite loop. + if (match[0].length == 0) + filter.lastIndex++; + } + + // Add any trailing text after the last match was found. + if (consumed < message.Text.length) { + if (consumed < index) + text.push(createElement("strong", {}, message.Text.slice(consumed, index))); + + if (index < message.Text.length) + text.push(message.Text.slice(index)); + } + } + + return createElement( + "tr", + { + class: [ + "mod", + level, + message.IsStartOfSection ? "section-start" : null + ], + attrs: { + "data-section": message.SectionName + }, + on: events + }, + [ + createElement("td", message.Time), + context.props.showScreenId ? createElement("td", message.ScreenId) : null, + createElement("td", level.toUpperCase()), + createElement( + "td", + { + attrs: { + "data-title": message.Mod + } + }, + message.Mod + ), + createElement( + "td", + [ + createElement( + "span", + { class: "log-message-text" }, + text + ), + message.IsStartOfSection + ? createElement( + "span", + { class: "section-toggle-message" }, + [ + " ", + toggleMessage + ] + ) + : null + ] + ) + ] + ); + } + }); // init app app = new Vue({ - el: '#output', - data: data, + el: "#output", + data: state, computed: { anyModsHidden: function () { return stats.modsHidden > 0; }, anyModsShown: function () { return stats.modsShown > 0; + }, + showScreenId: function () { + return this.data.screenIds.length > 1; + }, + + // Maybe not strictly necessary, but the Vue template is being + // weird about accessing data entries on the app rather than + // computed properties. + hideContentPacks: function () { + return !state.showContentPacks; + }, + + // Filter messages for visibility. + filterUseRegex: function () { + return state.useRegex; + }, + filterInsensitive: function () { + return state.useInsensitive; + }, + filterUseWord: function () { + return state.useWord; + }, + shouldHighlight: function () { + return state.useHighlight; + }, + + filteredMessages: function () { + if (!state.messages) + return []; + + //const start = performance.now(); + const filtered = []; + + let total = 0; + + // This is slightly faster than messages.filter(), which is + // important when working with absolutely huge logs. + for (let i = 0, length = state.messages.length; i < length; i++) { + const msg = state.messages[i]; + if (!this.filtersAllow(msg.ModSlug, msg.LevelName)) + continue; + + if (this.filterRegex) { + const text = msg.Text || (i > 0 ? state.messages[i - 1].Text : null); + this.filterRegex.lastIndex = -1; + if (!text || !this.filterRegex.test(text)) + continue; + } + + total++; + + if (msg.SectionName && !msg.IsStartOfSection && !this.sectionsAllow(msg.SectionName)) + continue; + + filtered.push(msg); + } + + filtered.total = total; + + Object.freeze(filtered); + + //const end = performance.now(); + //console.log(`applied ${(this.useRegex ? "regex" : "text")} filter '${this.filterRegex}' in ${end - start}ms`); + + return filtered; + }, + + // And the rest are about pagination. + start: function () { + return (this.page - 1) * state.perPage; + }, + end: function () { + return this.start + this.visibleMessages.length; + }, + totalPages: function () { + return Math.ceil(this.filteredMessages.length / state.perPage); + }, + // + visibleMessages: function () { + if (this.totalPages <= 1) + return this.filteredMessages; + + const start = this.start; + const end = start + state.perPage; + + return this.filteredMessages.slice(start, end); } }, + created: function () { + window.addEventListener("popstate", () => this.loadFromUrl()); + this.loadFromUrl(); + }, methods: { + loadFromUrl: function () { + const params = new URL(location).searchParams; + + state.perPage = helpers.tryParseNumber(params.get("PerPage"), defaultPerPage, n => n > 0); + this.page = helpers.tryParseNumber(params.get("Page"), 1, n => n > 0); + state.filterText = params.get("Filter") || ""; + + if (params.has("FilterMode")) { + const values = params.get("FilterMode").split("~"); + state.useRegex = values.includes("Regex"); + state.useInsensitive = !values.includes("Sensitive"); + state.useWord = values.includes("Word"); + } + else { + state.useRegex = false; + state.useInsensitive = true; + state.useWord = false; + } + + if (params.has("Mods")) { + const value = params.get("Mods").split("~"); + for (const key of Object.keys(this.showMods)) + this.showMods[key] = value.includes(key); + + } + else { + for (const key of Object.keys(this.showMods)) + this.showMods[key] = state.defaultMods[key]; + } + + if (params.has("Levels")) { + const values = params.get("Levels").split("~"); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = values.includes(key); + + } + else { + const keys = Object.keys(this.showLevels); + for (const key of Object.keys(this.showLevels)) + this.showLevels[key] = state.defaultLevels[key]; + } + + if (params.has("Sections")) { + const values = params.get("Sections").split("~"); + for (const key of Object.keys(this.showSections)) + this.showSections[key] = values.includes(key); + + } + else { + for (const key of Object.keys(this.showSections)) + this.showSections[key] = state.defaultSections[key]; + } + + this.updateModFilters(); + this.updateFilterText(); + }, + + /** + * Update the page URL to track non-default filter values. + */ + updateUrl: function () { + const url = new URL(location); + + if (state.page != 1 || state.perPage != defaultPerPage) { + url.searchParams.set("Page", state.page); + url.searchParams.set("PerPage", state.perPage); + } + else { + url.searchParams.delete("Page"); + url.searchParams.delete("PerPage"); + } + + if (!helpers.shallowEquals(this.showMods, state.defaultMods)) + url.searchParams.set("Mods", Object.entries(this.showMods).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Mods"); + + if (!helpers.shallowEquals(this.showLevels, state.defaultLevels)) + url.searchParams.set("Levels", Object.entries(this.showLevels).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Levels"); + + if (!helpers.shallowEquals(this.showSections, state.defaultSections)) + url.searchParams.set("Sections", Object.entries(this.showSections).filter(p => p[1]).map(p => p[0]).join("~")); + else + url.searchParams.delete("Sections"); + + if (state.filterText?.length) { + url.searchParams.set("Filter", state.filterText); + + const modes = []; + if (state.useRegex) + modes.push("Regex"); + if (!state.useInsensitive) + modes.push("Sensitive"); + if (state.useWord) + modes.push("Word"); + + if (modes.length) + url.searchParams.set("FilterMode", modes.join("~")); + else + url.searchParams.delete("FilterMode"); + + } + else { + url.searchParams.delete("Filter"); + url.searchParams.delete("FilterMode"); + } + + window.history.replaceState(null, document.title, url.toString()); // use replaceState instead of pushState to avoid filling the tab history with history steps the user probably doesn't care about + }, + toggleLevel: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showLevels[id] = !this.showLevels[id]; + this.updateUrl(); + }, + + toggleContentPacks: function () { + state.showContentPacks = !state.showContentPacks; + this.saveSettings(); + }, + + toggleFilterUseRegex: function () { + state.useRegex = !state.useRegex; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterInsensitive: function () { + state.useInsensitive = !state.useInsensitive; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleFilterWord: function () { + state.useWord = !state.useWord; + this.saveSettings(); + this.updateFilterText(); + }, + + toggleHighlight: function () { + state.useHighlight = !state.useHighlight; + this.saveSettings(); + }, + + prevPage: function () { + if (this.page <= 1) + return; + this.page--; + this.updateUrl(); + }, + + nextPage: function () { + if (this.page >= this.totalPages) + return; + this.page++; + this.updateUrl(); + }, + + changePage: function (page) { + if (page < 1 || page > this.totalPages) + return; + this.page = page; + this.updateUrl(); + }, + + /** + * Persist settings into localStorage for use the next time the user opens a log. + */ + saveSettings: function () { + localStorage.settings = JSON.stringify({ + showContentPacks: state.showContentPacks, + useRegex: state.useRegex, + useInsensitive: state.useInsensitive, + useWord: state.useWord, + useHighlight: state.useHighlight + }); + }, + + // We don't want to update the filter text often, so use a debounce with + // a quarter second delay. We basically always build a regular expression + // since we use it for highlighting, and it also make case insensitivity + // much easier. + updateFilterText: helpers.getDebouncedHandler( + function () { + // reset + this.filterError = null; + this.filterRegex = null; + + // apply search + let text = state.filterText; + if (!text) + this.filterText = ""; + else { + if (!state.useRegex) + text = helpers.escapeRegex(text); + + const flags = state.useInsensitive ? "ig" : "g"; + + try { + this.filterRegex = new RegExp(text, flags); + } + catch (err) { + this.filterError = err.message; + } + + if (this.filterRegex && state.useWord) + this.filterRegex = new RegExp(`\\b(?:${text})\\b`, flags); + } + + this.updateUrl(); + }, + 250 + ), + + updateModFilters: function () { + // counts + stats.modsShown = 0; + stats.modsHidden = 0; + for (let key in state.showMods) { + if (state.showMods.hasOwnProperty(key)) { + if (state.showMods[key]) + stats.modsShown++; + else + stats.modsHidden++; + } + } }, toggleMod: function (id) { - if (!data.enableFilters) + if (!state.enableFilters) return; - var curShown = this.showMods[id]; + const curShown = this.showMods[id]; // first filter: only show this by default if (stats.modsHidden === 0) { @@ -66,38 +932,42 @@ smapi.logParser = function (data, sectionUrl) { else this.showMods[id] = !this.showMods[id]; - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, toggleSection: function (name) { - if (!data.enableFilters) + if (!state.enableFilters) return; this.showSections[name] = !this.showSections[name]; + this.updateUrl(); }, showAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = true; } } - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, hideAllMods: function () { - if (!data.enableFilters) + if (!state.enableFilters) return; - for (var key in this.showMods) { + for (let key in this.showMods) { if (this.showMods.hasOwnProperty(key)) { this.showMods[key] = false; } } - updateModFilters(); + this.updateModFilters(); + this.updateUrl(); }, filtersAllow: function (modId, level) { @@ -113,7 +983,7 @@ smapi.logParser = function (data, sectionUrl) { /********** ** Upload form *********/ - var input = $("#input"); + const input = $("#input"); if (input.length) { // file upload smapi.fileUpload({ diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 6b80f260..4975a973 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -14,9 +14,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features, avoid obsolete behavior, and reduce load times.", "type": "string", - "const": "1.24.0", + "const": "1.25.0", "@errorMessages": { - "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.24.0) to enable the latest features, avoid obsolete behavior, and reduce load times." + "const": "Incorrect value '@value'. You should always use the latest format version (currently 1.25.0) to enable the latest features, avoid obsolete behavior, and reduce load times." } }, "ConfigSchema": { @@ -159,6 +159,14 @@ "additionalProperties": false } }, + "AliasTokenNames": { + "title": "Alias token names", + "description": "Defines optional alternate name for existing tokens. This only affects your content pack, and you can use both the alias name and the original token name. This is mostly useful for custom tokens provided by other mods, which often have longer names. Each entry key is the alias name, and the value is the original token name.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "Changes": { "title": "Changes", "description": "The changes you want to make. Each entry is called a patch, and describes a specific action to perform: replace this file, copy this image into the file, etc. You can list any number of patches.", @@ -187,22 +195,6 @@ "description": "A name for this patch shown in log messages. This is very useful for understanding errors; if not specified, will default to a name like 'entry #14 (EditImage Animals/Dinosaurs)'.", "type": "string" }, - "Enabled": { - "title": "Enabled", - "description": "Whether to apply this patch. Default true. This field does not allow tokens.", - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ "true", "false" ] - } - ], - "@errorMessages": { - "anyOf": "Invalid value; must be true, false, or a single token which evaluates to true or false." - } - }, "Update": { "title": "Update", "description": "When the patch should update if it changed. The possible values are 'OnDayStart', 'OnLocationChange', or 'OnTimeChange' (defaults to OnDayStart).", @@ -241,6 +233,14 @@ "description": "The part of the target image to replace. Defaults to the FromArea size starting from the top-left corner.", "$ref": "#/definitions/Rectangle" }, + "TargetField": { + "title": "Target field", + "description": "The path to the field within the value to set as the root scope. See 'target field' in the EditData documentation for more info. This field supports tokens.", + "type": "array", + "items": { + "type": "string" + } + }, "Fields": { "title": "Fields", "description": "The individual fields you want to change for existing entries. This field supports tokens in field keys and values. The key for each field is the field index (starting at zero) for a slash-delimited string, or the field name for an object.", @@ -408,7 +408,6 @@ "propertyNames": { "enum": [ "Action", - "Enabled", "FromFile", "LogName", "Target", @@ -438,7 +437,6 @@ "propertyNames": { "enum": [ "Action", - "Enabled", "FromFile", "LogName", "Target", @@ -462,12 +460,12 @@ "propertyNames": { "enum": [ "Action", - "Enabled", "LogName", "Target", "Update", "When", + "TargetField", "Entries", "Fields", "MoveEntries", @@ -504,7 +502,6 @@ "propertyNames": { "enum": [ "Action", - "Enabled", "FromFile", "LogName", "Target", @@ -533,7 +530,6 @@ "propertyNames": { "enum": [ "Action", - "Enabled", "FromFile", "LogName", "Update", diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json index b6722347..7457b993 100644 --- a/src/SMAPI.Web/wwwroot/schemas/manifest.json +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -103,7 +103,7 @@ "type": "array", "items": { "type": "string", - "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)$", + "pattern": "^(?i)(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_\\-]+/[A-Za-z0-9_\\-]+|ModDrop:\\d+)(?: *@ *[a-zA-Z0-9_]+ *)?$", "@errorMessages": { "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." } diff --git a/src/SMAPI.sln b/src/SMAPI.sln index be5326f7..d9f60a5c 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -101,6 +101,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "lib", "lib", "{3B5BF14D-F61 ..\build\windows\lib\in-place-regex.ps1 = ..\build\windows\lib\in-place-regex.ps1 EndProjectSection EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests.ModApiProvider", "SMAPI.Tests.ModApiProvider\SMAPI.Tests.ModApiProvider.csproj", "{239AEEAC-07D1-4A3F-AA99-8C74F5038F50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SMAPI.Tests.ModApiConsumer", "SMAPI.Tests.ModApiConsumer\SMAPI.Tests.ModApiConsumer.csproj", "{2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}" +EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution SMAPI.Internal\SMAPI.Internal.projitems*{0634ea4c-3b8f-42db-aea6-ca9e4ef6e92f}*SharedItemsImports = 5 @@ -167,6 +171,14 @@ Global {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.Build.0 = Debug|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.ActiveCfg = Release|Any CPU {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Release|Any CPU.Build.0 = Release|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50}.Release|Any CPU.Build.0 = Release|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -187,6 +199,8 @@ Global {4D661178-38FB-43E4-AA5F-9B0406919344} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} {CAA1488E-842B-433D-994D-1D3D0B5DD125} = {09CF91E5-5BAB-4650-A200-E5EA9A633046} {3B5BF14D-F612-4C83-9EF6-E3EBFCD08766} = {4D661178-38FB-43E4-AA5F-9B0406919344} + {239AEEAC-07D1-4A3F-AA99-8C74F5038F50} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {2A4DF030-E8B1-4BBD-AA93-D4DE68CB9D85} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC} diff --git a/src/SMAPI.sln.DotSettings b/src/SMAPI.sln.DotSettings index 71cd7b82..c8dcdb55 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -25,14 +25,22 @@ <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=analytics/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=backports/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=bigcraftable/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=bigcraftables/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Chucklefish/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=clickable/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Comparers/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=craftable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=craftables/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=crossplatform/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=cutscene/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=debounce/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=debounced/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=decoratable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=devs/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=disambiguator/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=equippable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=fallbacks/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=filenames/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=gamepad/@EntryIndexedValue">True</s:Boolean> @@ -42,6 +50,8 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=Junimo/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Keybind/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=keybinds/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Lidgren/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=minigames/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=modder/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=modders/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Mongo/@EntryIndexedValue">True</s:Boolean> @@ -51,10 +61,16 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=Pastebin/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=pathfinding/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Pathoschild/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Pintail/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=premultiplied/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=premultiply/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Prenormalize/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Preprocesses/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=prerelease/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=proxying/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=pufferchick/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=rasterizer/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=reimplements/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriter/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=rewriters/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=SMAPI/@EntryIndexedValue">True</s:Boolean> @@ -63,14 +79,20 @@ <s:Boolean x:Key="/Default/UserDictionary/Words/=spritesheet/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=stackable/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Stardew/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=subchain/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=subdomain/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=subkey/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=synchronised/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=tbin/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=textbox/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=thumbstick/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheet/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheets/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tilesheet_0027s/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=transpiler/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=unloadable/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=unlocalized/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=versioning/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=virally/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Xbox/@EntryIndexedValue">True</s:Boolean> </wpf:ResourceDictionary>
\ No newline at end of file diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index 667491d6..f5da286a 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -6,6 +6,7 @@ using System.Reflection; using Mono.Cecil; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework; using StardewModdingAPI.Toolkit.Utilities; @@ -31,10 +32,10 @@ namespace StardewModdingAPI ** Accessors *********/ /// <summary>The path to the game folder.</summary> - public static string ExecutionPath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); + public static string GamePath { get; } = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; /// <summary>The absolute path to the folder containing SMAPI's internal files.</summary> - public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.ExecutionPath, "smapi-internal"); + public static readonly string InternalFilesPath = Path.Combine(EarlyConstants.GamePath, "smapi-internal"); /// <summary>The target game platform.</summary> internal static GamePlatform Platform { get; } = (GamePlatform)Enum.Parse(typeof(GamePlatform), LowLevelEnvironmentUtility.DetectPlatform()); @@ -49,7 +50,7 @@ namespace StardewModdingAPI internal static int? LogScreenId { get; set; } /// <summary>SMAPI's current raw semantic version.</summary> - internal static string RawApiVersion = "3.13.4"; + internal static string RawApiVersion = "3.14.0"; } /// <summary>Contains SMAPI's constants and assumptions.</summary> @@ -67,8 +68,8 @@ namespace StardewModdingAPI /// <summary>The minimum supported version of Stardew Valley.</summary> public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.5.6"); - /// <summary>The maximum supported version of Stardew Valley.</summary> - public static ISemanticVersion MaximumGameVersion { get; } = null; + /// <summary>The maximum supported version of Stardew Valley, if any.</summary> + public static ISemanticVersion? MaximumGameVersion { get; } = null; /// <summary>The target game platform.</summary> public static GamePlatform TargetPlatform { get; } = EarlyConstants.Platform; @@ -77,7 +78,27 @@ namespace StardewModdingAPI public static GameFramework GameFramework { get; } = EarlyConstants.GameFramework; /// <summary>The path to the game folder.</summary> - public static string ExecutionPath { get; } = EarlyConstants.ExecutionPath; + [Obsolete($"Use {nameof(Constants)}.{nameof(GamePath)} instead. This property will be removed in SMAPI 4.0.0.")] + public static string ExecutionPath + { + get + { + SCore.DeprecationManager.Warn( + source: SCore.DeprecationManager.GetModFromStack(), + nounPhrase: $"{nameof(Constants)}.{nameof(Constants.ExecutionPath)}", + version: "3.14.0", + severity: DeprecationLevel.Notice + ); + + return Constants.GamePath; + } + } + + /// <summary>The path to the game folder.</summary> + public static string GamePath { get; } = EarlyConstants.GamePath; + + /// <summary>The path to the game's <c>Content</c> folder.</summary> + public static string ContentPath { get; } = Constants.GetContentFolderPath(); /// <summary>The directory path containing Stardew Valley's app data.</summary> public static string DataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley"); @@ -89,10 +110,10 @@ namespace StardewModdingAPI public static string SavesPath { get; } = Path.Combine(Constants.DataPath, "Saves"); /// <summary>The name of the current save folder (if save info is available, regardless of whether the save file exists yet).</summary> - public static string SaveFolderName => Constants.GetSaveFolderName(); + public static string? SaveFolderName => Constants.GetSaveFolderName(); /// <summary>The absolute path to the current save folder (if save info is available and the save file exists).</summary> - public static string CurrentSavePath => Constants.GetSaveFolderPathIfExists(); + public static string? CurrentSavePath => Constants.GetSaveFolderPathIfExists(); /**** ** Internal @@ -139,10 +160,10 @@ namespace StardewModdingAPI internal static string UpdateMarker => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.update.marker"); /// <summary>The default full path to search for mods.</summary> - internal static string DefaultModsPath { get; } = Path.Combine(Constants.ExecutionPath, "Mods"); + internal static string DefaultModsPath { get; } = Path.Combine(Constants.GamePath, "Mods"); /// <summary>The actual full path to search for mods.</summary> - internal static string ModsPath { get; set; } + internal static string ModsPath { get; set; } = null!; // initialized early during SMAPI startup /// <summary>The game's current semantic version.</summary> internal static ISemanticVersion GameVersion { get; } = new GameVersion(Game1.version); @@ -150,9 +171,6 @@ namespace StardewModdingAPI /// <summary>The target game platform as a SMAPI toolkit constant.</summary> internal static Platform Platform { get; } = (Platform)Constants.TargetPlatform; - /// <summary>The language code for non-translated mod assets.</summary> - internal static LocalizedContentManager.LanguageCode DefaultLanguage { get; } = LocalizedContentManager.LanguageCode.en; - /********* ** Internal methods @@ -160,7 +178,7 @@ namespace StardewModdingAPI /// <summary>Get the SMAPI version to recommend for an older game version, if any.</summary> /// <param name="version">The game version to search.</param> /// <returns>Returns the compatible SMAPI version, or <c>null</c> if none was found.</returns> - internal static ISemanticVersion GetCompatibleApiVersion(ISemanticVersion version) + internal static ISemanticVersion? GetCompatibleApiVersion(ISemanticVersion version) { // This covers all officially supported public game updates. It might seem like version // ranges would be better, but the given SMAPI versions may not be compatible with @@ -222,7 +240,7 @@ namespace StardewModdingAPI internal static void ConfigureAssemblyResolver(AssemblyDefinitionResolver resolver) { // add search paths - resolver.AddSearchDirectory(Constants.ExecutionPath); + resolver.AddSearchDirectory(Constants.GamePath); resolver.AddSearchDirectory(Constants.InternalFilesPath); // add SMAPI explicitly @@ -274,42 +292,66 @@ namespace StardewModdingAPI return new PlatformAssemblyMap(targetPlatform, removeAssemblyReferences.ToArray(), targetAssemblies.ToArray()); } - /// <summary>Get whether the game assembly was patched by Stardew64Installer.</summary> - /// <param name="version">The version of Stardew64Installer which was applied to the game assembly, if any.</param> - internal static bool IsPatchedByStardew64Installer(out ISemanticVersion version) + + /********* + ** Private methods + *********/ + /// <summary>Get the absolute path to the game's <c>Content</c> folder.</summary> + private static string GetContentFolderPath() { - PropertyInfo property = typeof(Game1).GetProperty("Stardew64InstallerVersion"); - if (property == null) + // + // We can't use Path.Combine(Constants.GamePath, Game1.content.RootDirectory) here, + // since Game1.content isn't initialized until later in the game startup. + // + + string gamePath = EarlyConstants.GamePath; + + // most platforms + if (EarlyConstants.Platform != GamePlatform.Mac) + return Path.Combine(gamePath, "Content"); + + // macOS + string[] paths = new[] + { + // GOG + // - game: Stardew Valley.app/Contents/MacOS + // - content: Stardew Valley.app/Resources/Content + "../../Resources/Content", + + // Steam + // - game: StardewValley/Contents/MacOS + // - content: StardewValley/Contents/Resources/Content + "../Resources/Content" + } + .Select(path => Path.GetFullPath(Path.Combine(gamePath, path))) + .ToArray(); + + foreach (string path in paths) { - version = null; - return false; + if (Directory.Exists(path)) + return path; } - version = new SemanticVersion((string)property.GetValue(null)); - return true; + return paths.Last(); } - - /********* - ** Private methods - *********/ /// <summary>Get the name of the save folder, if any.</summary> - private static string GetSaveFolderName() + private static string? GetSaveFolderName() { return Constants.GetSaveFolder()?.Name; } - /// <summary>Get the path to the current save folder, if any.</summary> - private static string GetSaveFolderPathIfExists() + /// <summary>Get the absolute path to the current save folder, if any.</summary> + private static string? GetSaveFolderPathIfExists() { - DirectoryInfo saveFolder = Constants.GetSaveFolder(); + DirectoryInfo? saveFolder = Constants.GetSaveFolder(); return saveFolder?.Exists == true ? saveFolder.FullName : null; } /// <summary>Get the current save folder, if any.</summary> - private static DirectoryInfo GetSaveFolder() + private static DirectoryInfo? GetSaveFolder() { // save not available if (Context.LoadStage == LoadStage.None) @@ -322,7 +364,7 @@ namespace StardewModdingAPI : Game1.uniqueIDForThisGame; // get best match (accounting for rare case where folder name isn't sanitized) - DirectoryInfo folder = null; + DirectoryInfo? folder = null; foreach (string saveName in new[] { rawSaveName, new string(rawSaveName.Where(char.IsLetterOrDigit).ToArray()) }) { try diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index a745592c..aa4ecf35 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -14,16 +14,16 @@ namespace StardewModdingAPI ** Fields *********/ /// <summary>Whether the player has loaded a save and the world has finished initializing.</summary> - private static readonly PerScreen<bool> IsWorldReadyForScreen = new PerScreen<bool>(); + private static readonly PerScreen<bool> IsWorldReadyForScreen = new(); /// <summary>The current stage in the game's loading process.</summary> - private static readonly PerScreen<LoadStage> LoadStageForScreen = new PerScreen<LoadStage>(); + private static readonly PerScreen<LoadStage> LoadStageForScreen = new(); /// <summary>Whether a player save has been loaded.</summary> - internal static bool IsSaveLoaded => Game1.hasLoadedGame && !(Game1.activeClickableMenu is TitleMenu); + internal static bool IsSaveLoaded => Game1.hasLoadedGame && Game1.activeClickableMenu is not TitleMenu; /// <summary>Whether the game is currently writing to the save file.</summary> - internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu || Game1.activeClickableMenu is ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something + internal static bool IsSaving => Game1.activeClickableMenu is SaveGameMenu or ShippingMenu; // saving is performed by SaveGameMenu, but it's wrapped by ShippingMenu on days when the player shipping something /// <summary>The active split-screen instance IDs.</summary> internal static readonly ISet<int> ActiveScreenIds = new HashSet<int>(); @@ -39,7 +39,7 @@ namespace StardewModdingAPI } /// <summary>Whether the in-game world is completely unloaded and not in the process of being loaded. The world may still exist in memory at this point, but should be ignored.</summary> - internal static bool IsWorldFullyUnloaded => Context.LoadStage == LoadStage.ReturningToTitle || Context.LoadStage == LoadStage.None; + internal static bool IsWorldFullyUnloaded => Context.LoadStage is LoadStage.ReturningToTitle or LoadStage.None; /********* @@ -86,7 +86,7 @@ namespace StardewModdingAPI public static bool HasRemotePlayers => Context.IsMultiplayer && !Game1.hasLocalClientsOnly; /// <summary>Whether the current player is the main player. This is always true in single-player, and true when hosting in multiplayer.</summary> - public static bool IsMainPlayer => Game1.IsMasterGame && Context.ScreenId == 0 && !(TitleMenu.subMenu is FarmhandMenu); + public static bool IsMainPlayer => Game1.IsMasterGame && Context.ScreenId == 0 && TitleMenu.subMenu is not FarmhandMenu; /********* diff --git a/src/SMAPI/Events/AssetEditPriority.cs b/src/SMAPI/Events/AssetEditPriority.cs new file mode 100644 index 00000000..d41dfd7d --- /dev/null +++ b/src/SMAPI/Events/AssetEditPriority.cs @@ -0,0 +1,16 @@ +namespace StardewModdingAPI.Events +{ + /// <summary>The priority for an asset edit when multiple apply for the same asset.</summary> + /// <remarks>You can also specify arbitrary intermediate values, like <c>AssetLoadPriority.Low + 5</c>.</remarks> + public enum AssetEditPriority + { + /// <summary>This edit should be applied before (i.e. 'under') <see cref="Default"/> edits.</summary> + Early = -1000, + + /// <summary>The default priority.</summary> + Default = 0, + + /// <summary>This edit should be applied after (i.e. 'on top of') <see cref="Default"/> edits.</summary> + Late = 1000 + } +} diff --git a/src/SMAPI/Events/AssetLoadPriority.cs b/src/SMAPI/Events/AssetLoadPriority.cs new file mode 100644 index 00000000..e07b5a40 --- /dev/null +++ b/src/SMAPI/Events/AssetLoadPriority.cs @@ -0,0 +1,19 @@ +namespace StardewModdingAPI.Events +{ + /// <summary>The priority for an asset load when multiple apply for the same asset.</summary> + /// <remarks>If multiple non-<see cref="Exclusive"/> loads have the same priority, the one registered first will be selected. You can also specify arbitrary intermediate values, like <c>AssetLoadPriority.Low + 5</c>.</remarks> + public enum AssetLoadPriority + { + /// <summary>This load is optional and can safely be skipped if there are higher-priority loads.</summary> + Low = -1000, + + /// <summary>The load is optional and can safely be skipped if there are higher-priority loads, but it should still be preferred over any <see cref="Low"/>-priority loads.</summary> + Medium = 0, + + /// <summary>The load is optional and can safely be skipped if there are higher-priority loads, but it should still be preferred over any <see cref="Low"/>- or <see cref="Medium"/>-priority loads.</summary> + High = 1000, + + /// <summary>The load is not optional. If more than one loader has <see cref="Exclusive"/> priority, SMAPI will log an error and ignore all of them.</summary> + Exclusive = int.MaxValue + } +} diff --git a/src/SMAPI/Events/AssetReadyEventArgs.cs b/src/SMAPI/Events/AssetReadyEventArgs.cs new file mode 100644 index 00000000..2c308f18 --- /dev/null +++ b/src/SMAPI/Events/AssetReadyEventArgs.cs @@ -0,0 +1,31 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IContentEvents.AssetReady"/> event.</summary> + public class AssetReadyEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The name of the asset being requested.</summary> + public IAssetName Name { get; } + + /// <summary>The <see cref="Name"/> with any locale codes stripped.</summary> + /// <remarks>For example, if <see cref="Name"/> contains a locale like <c>Data/Bundles.fr-FR</c>, this will be the name without locale like <c>Data/Bundles</c>. If the name has no locale, this field is equivalent.</remarks> + public IAssetName NameWithoutLocale { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="name">The name of the asset being requested.</param> + /// <param name="nameWithoutLocale">The <paramref name="name"/> with any locale codes stripped.</param> + internal AssetReadyEventArgs(IAssetName name, IAssetName nameWithoutLocale) + { + this.Name = name; + this.NameWithoutLocale = nameWithoutLocale; + } + } +} diff --git a/src/SMAPI/Events/AssetRequestedEventArgs.cs b/src/SMAPI/Events/AssetRequestedEventArgs.cs new file mode 100644 index 00000000..3bcf83b9 --- /dev/null +++ b/src/SMAPI/Events/AssetRequestedEventArgs.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Content; +using xTile; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IContentEvents.AssetRequested"/> event.</summary> + public class AssetRequestedEventArgs : EventArgs + { + /********* + ** Fields + *********/ + /// <summary>The mod handling the event.</summary> + private readonly IModMetadata Mod; + + /// <summary>Get the mod metadata for a content pack, if it's a valid content pack for the mod.</summary> + private readonly Func<IModMetadata, string?, string, IModMetadata?> GetOnBehalfOf; + + + /********* + ** Accessors + *********/ + /// <summary>The name of the asset being requested.</summary> + public IAssetName Name { get; } + + /// <summary>The <see cref="Name"/> with any locale codes stripped.</summary> + /// <remarks>For example, if <see cref="Name"/> contains a locale like <c>Data/Bundles.fr-FR</c>, this will be the name without locale like <c>Data/Bundles</c>. If the name has no locale, this field is equivalent.</remarks> + public IAssetName NameWithoutLocale { get; } + + /// <summary>The requested data type.</summary> + public Type DataType { get; } + + /// <summary>The load operations requested by the event handler.</summary> + internal IList<AssetLoadOperation> LoadOperations { get; } = new List<AssetLoadOperation>(); + + /// <summary>The edit operations requested by the event handler.</summary> + internal IList<AssetEditOperation> EditOperations { get; } = new List<AssetEditOperation>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod handling the event.</param> + /// <param name="name">The name of the asset being requested.</param> + /// <param name="dataType">The requested data type.</param> + /// <param name="nameWithoutLocale">The <paramref name="name"/> with any locale codes stripped.</param> + /// <param name="getOnBehalfOf">Get the mod metadata for a content pack, if it's a valid content pack for the mod.</param> + internal AssetRequestedEventArgs(IModMetadata mod, IAssetName name, IAssetName nameWithoutLocale, Type dataType, Func<IModMetadata, string?, string, IModMetadata?> getOnBehalfOf) + { + this.Mod = mod; + this.Name = name; + this.NameWithoutLocale = nameWithoutLocale; + this.DataType = dataType; + this.GetOnBehalfOf = getOnBehalfOf; + } + + /// <summary>Provide the initial instance for the asset, instead of trying to load it from the game's <c>Content</c> folder.</summary> + /// <param name="load">Get the initial instance of an asset.</param> + /// <param name="priority">If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</param> + /// <param name="onBehalfOf">The content pack ID on whose behalf you're applying the change. This is only valid for content packs for your mod.</param> + /// <remarks> + /// Usage notes: + /// <list type="bullet"> + /// <item>The asset doesn't need to exist in the game's <c>Content</c> folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder.</item> + /// <item>Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will use the <paramref name="priority"/> parameter to decide what happens. If you're making changes to the existing asset instead of replacing it, you should use <see cref="Edit"/> instead to avoid those limitations and improve mod compatibility.</item> + /// </list> + /// </remarks> + public void LoadFrom(Func<object> load, AssetLoadPriority priority, string? onBehalfOf = null) + { + this.LoadOperations.Add( + new AssetLoadOperation( + mod: this.Mod, + priority: priority, + onBehalfOf: this.GetOnBehalfOf(this.Mod, onBehalfOf, "load assets"), + getData: _ => load() + ) + ); + } + + /// <summary>Provide the initial instance for the asset from a file in your mod folder, instead of trying to load it from the game's <c>Content</c> folder.</summary> + /// <typeparam name="TAsset">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="relativePath">The relative path to the file in your mod folder.</param> + /// <param name="priority">If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</param> + /// <remarks> + /// Usage notes: + /// <list type="bullet"> + /// <item>The asset doesn't need to exist in the game's <c>Content</c> folder. If any mod loads the asset, the game will see it as an existing asset as if it was in that folder.</item> + /// <item>Each asset can logically only have one initial instance. If multiple loads apply at the same time, SMAPI will raise an error and ignore all of them. If you're making changes to the existing asset instead of replacing it, you should use <see cref="Edit"/> instead to avoid those limitations and improve mod compatibility.</item> + /// </list> + /// </remarks> + public void LoadFromModFile<TAsset>(string relativePath, AssetLoadPriority priority) + where TAsset : notnull + { + this.LoadOperations.Add( + new AssetLoadOperation( + mod: this.Mod, + priority: priority, + onBehalfOf: null, + _ => this.Mod.Mod!.Helper.ModContent.Load<TAsset>(relativePath) + ) + ); + } + + /// <summary>Edit the asset after it's loaded.</summary> + /// <param name="apply">Apply changes to the asset.</param> + /// <param name="priority">If there are multiple edits that apply to the same asset, the priority with which this one should be applied.</param> + /// <param name="onBehalfOf">The content pack ID on whose behalf you're applying the change. This is only valid for content packs for your mod.</param> + /// <remarks> + /// Usage notes: + /// <list type="bullet"> + /// <item>Editing an asset which doesn't exist has no effect. This is applied after the asset is loaded from the game's <c>Content</c> folder, or from any mod's <see cref="LoadFrom"/> or <see cref="LoadFromModFile{TAsset}"/>.</item> + /// <item>You can apply any number of edits to the asset. Each edit will be applied on top of the previous one (i.e. it'll see the merged asset from all previous edits as its input).</item> + /// </list> + /// </remarks> + public void Edit(Action<IAssetData> apply, AssetEditPriority priority = AssetEditPriority.Default, string? onBehalfOf = null) + { + this.EditOperations.Add( + new AssetEditOperation( + mod: this.Mod, + priority: priority, + onBehalfOf: this.GetOnBehalfOf(this.Mod, onBehalfOf, "edit assets"), + apply + ) + ); + } + } +} diff --git a/src/SMAPI/Events/AssetsInvalidatedEventArgs.cs b/src/SMAPI/Events/AssetsInvalidatedEventArgs.cs new file mode 100644 index 00000000..614cdf49 --- /dev/null +++ b/src/SMAPI/Events/AssetsInvalidatedEventArgs.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IContentEvents.AssetsInvalidated"/> event.</summary> + public class AssetsInvalidatedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The asset names that were invalidated.</summary> + public IReadOnlySet<IAssetName> Names { get; } + + /// <summary>The <see cref="Names"/> with any locale codes stripped.</summary> + /// <remarks>For example, if <see cref="Names"/> contains a locale like <c>Data/Bundles.fr-FR</c>, this will have the name without locale like <c>Data/Bundles</c>. If the name has no locale, this field is equivalent.</remarks> + public IReadOnlySet<IAssetName> NamesWithoutLocale { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="names">The asset names that were invalidated.</param> + /// <param name="namesWithoutLocale">The <paramref name="names"/> with any locale codes stripped.</param> + internal AssetsInvalidatedEventArgs(IEnumerable<IAssetName> names, IEnumerable<IAssetName> namesWithoutLocale) + { + this.Names = names.ToImmutableHashSet(); + this.NamesWithoutLocale = namesWithoutLocale.ToImmutableHashSet(); + } + } +} diff --git a/src/SMAPI/Events/ButtonsChangedEventArgs.cs b/src/SMAPI/Events/ButtonsChangedEventArgs.cs index dda41692..a5e87735 100644 --- a/src/SMAPI/Events/ButtonsChangedEventArgs.cs +++ b/src/SMAPI/Events/ButtonsChangedEventArgs.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Events foreach (var state in new[] { SButtonState.Pressed, SButtonState.Held, SButtonState.Released }) { if (!lookup.ContainsKey(state)) - lookup[state] = new SButton[0]; + lookup[state] = Array.Empty<SButton>(); } return lookup; diff --git a/src/SMAPI/Events/ChangeType.cs b/src/SMAPI/Events/ChangeType.cs deleted file mode 100644 index 0fc717df..00000000 --- a/src/SMAPI/Events/ChangeType.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace StardewModdingAPI.Events -{ - /// <summary>Indicates how an inventory item changed.</summary> - public enum ChangeType - { - /// <summary>The entire stack was removed.</summary> - Removed, - - /// <summary>The entire stack was added.</summary> - Added, - - /// <summary>The stack size changed.</summary> - StackChange - } -} diff --git a/src/SMAPI/Events/IContentEvents.cs b/src/SMAPI/Events/IContentEvents.cs new file mode 100644 index 00000000..d537db70 --- /dev/null +++ b/src/SMAPI/Events/IContentEvents.cs @@ -0,0 +1,27 @@ +using System; + +namespace StardewModdingAPI.Events +{ + /// <summary>Events related to assets loaded from the content pipeline (including data, maps, and textures).</summary> + public interface IContentEvents + { + /// <summary>Raised when an asset is being requested from the content pipeline.</summary> + /// <remarks> + /// The asset isn't necessarily being loaded yet (e.g. the game may be checking if it exists). Mods can register the changes they want to apply using methods on the event arguments. These will be applied when the asset is actually loaded. + /// + /// If the asset is requested multiple times in the same tick (e.g. once to check if it exists and once to load it), SMAPI might only raise the event once and reuse the cached result. + /// </remarks> + event EventHandler<AssetRequestedEventArgs> AssetRequested; + + /// <summary>Raised after one or more assets were invalidated from the content cache by a mod, so they'll be reloaded next time they're requested. If the assets will be reloaded or propagated automatically, this event is raised before that happens.</summary> + event EventHandler<AssetsInvalidatedEventArgs> AssetsInvalidated; + + /// <summary>Raised after an asset is loaded by the content pipeline, after all mod edits specified via <see cref="AssetRequested"/> have been applied.</summary> + /// <remarks>This event is only raised if something requested the asset from the content pipeline. Invalidating an asset from the content cache won't necessarily reload it automatically.</remarks> + event EventHandler<AssetReadyEventArgs> AssetReady; + + /// <summary>Raised after the game language changes.</summary> + /// <remarks>For non-English players, this may be raised during startup when the game switches to the previously selected language.</remarks> + event EventHandler<LocaleChangedEventArgs> LocaleChanged; + } +} diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs index 1f892b31..2603961b 100644 --- a/src/SMAPI/Events/IModEvents.cs +++ b/src/SMAPI/Events/IModEvents.cs @@ -3,6 +3,9 @@ namespace StardewModdingAPI.Events /// <summary>Manages access to events raised by SMAPI.</summary> public interface IModEvents { + /// <summary>Events related to assets loaded from the content pipeline (including data, maps, and textures).</summary> + IContentEvents Content { get; } + /// <summary>Events related to UI and drawing to the screen.</summary> IDisplayEvents Display { get; } diff --git a/src/SMAPI/Events/LocaleChangedEventArgs.cs b/src/SMAPI/Events/LocaleChangedEventArgs.cs new file mode 100644 index 00000000..09d3f6e5 --- /dev/null +++ b/src/SMAPI/Events/LocaleChangedEventArgs.cs @@ -0,0 +1,45 @@ +using System; +using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode; + +namespace StardewModdingAPI.Events +{ + /// <summary>Event arguments for an <see cref="IContentEvents.LocaleChanged"/> event.</summary> + public class LocaleChangedEventArgs : EventArgs + { + /********* + ** Accessors + *********/ + /// <summary>The previous language enum value.</summary> + /// <remarks>For a custom language, this is always <see cref="LanguageCode.mod"/>.</remarks> + public LanguageCode OldLanguage { get; } + + /// <summary>The previous locale code.</summary> + /// <remarks>This is the locale code as it appears in asset names, like <c>fr-FR</c> in <c>Maps/springobjects.fr-FR</c>. The locale code for English is an empty string.</remarks> + public string OldLocale { get; } + + /// <summary>The new language enum value.</summary> + /// <remarks><inheritdoc cref="OldLanguage" select="remarks" /></remarks> + public LanguageCode NewLanguage { get; } + + /// <summary>The new locale code.</summary> + /// <remarks><inheritdoc cref="OldLocale" select="remarks" /></remarks> + public string NewLocale { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="oldLanguage">The previous language enum value.</param> + /// <param name="oldLocale">The previous locale code.</param> + /// <param name="newLanguage">The new language enum value.</param> + /// <param name="newLocale">The new locale code.</param> + internal LocaleChangedEventArgs(LanguageCode oldLanguage, string oldLocale, LanguageCode newLanguage, string newLocale) + { + this.OldLanguage = oldLanguage; + this.OldLocale = oldLocale; + this.NewLanguage = newLanguage; + this.NewLocale = newLocale; + } + } +} diff --git a/src/SMAPI/Events/MenuChangedEventArgs.cs b/src/SMAPI/Events/MenuChangedEventArgs.cs index 977ba38b..c37fd216 100644 --- a/src/SMAPI/Events/MenuChangedEventArgs.cs +++ b/src/SMAPI/Events/MenuChangedEventArgs.cs @@ -9,20 +9,20 @@ namespace StardewModdingAPI.Events /********* ** Accessors *********/ - /// <summary>The previous menu.</summary> - public IClickableMenu OldMenu { get; } + /// <summary>The previous menu, if any.</summary> + public IClickableMenu? OldMenu { get; } - /// <summary>The current menu.</summary> - public IClickableMenu NewMenu { get; } + /// <summary>The current menu, if any.</summary> + public IClickableMenu? NewMenu { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="oldMenu">The previous menu.</param> - /// <param name="newMenu">The current menu.</param> - internal MenuChangedEventArgs(IClickableMenu oldMenu, IClickableMenu newMenu) + /// <param name="oldMenu">The previous menu, if any.</param> + /// <param name="newMenu">The current menu, if any.</param> + internal MenuChangedEventArgs(IClickableMenu? oldMenu, IClickableMenu? newMenu) { this.OldMenu = oldMenu; this.NewMenu = newMenu; diff --git a/src/SMAPI/Events/ModMessageReceivedEventArgs.cs b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs index d75a7540..84a27d18 100644 --- a/src/SMAPI/Events/ModMessageReceivedEventArgs.cs +++ b/src/SMAPI/Events/ModMessageReceivedEventArgs.cs @@ -45,8 +45,10 @@ namespace StardewModdingAPI.Events /// <summary>Read the message data into the given model type.</summary> /// <typeparam name="TModel">The message model type.</typeparam> public TModel ReadAs<TModel>() + where TModel : notnull { - return this.Message.Data.ToObject<TModel>(this.JsonHelper.GetSerializer()); + return this.Message.Data.ToObject<TModel>(this.JsonHelper.GetSerializer()) + ?? throw new InvalidOperationException($"Can't read empty mod message data as a {typeof(TModel).FullName} value."); } } } diff --git a/src/SMAPI/Framework/Command.cs b/src/SMAPI/Framework/Command.cs index 8c9df47d..dca1dd09 100644 --- a/src/SMAPI/Framework/Command.cs +++ b/src/SMAPI/Framework/Command.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI.Framework ** Accessor *********/ /// <summary>The mod that registered the command (or <c>null</c> if registered by SMAPI).</summary> - public IModMetadata Mod { get; } + public IModMetadata? Mod { get; } /// <summary>The command name, which the user must type to trigger it.</summary> public string Name { get; } @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework /// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> - public Command(IModMetadata mod, string name, string documentation, Action<string, string[]> callback) + public Command(IModMetadata? mod, string name, string documentation, Action<string, string[]> callback) { this.Mod = mod; this.Name = name; diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index ff540ad8..d3b9c8ee 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text; using StardewModdingAPI.Framework.Commands; @@ -34,20 +35,19 @@ namespace StardewModdingAPI.Framework /// <param name="name">The command name, which the user must type to trigger it.</param> /// <param name="documentation">The human-readable documentation shown when the player runs the built-in 'help' command.</param> /// <param name="callback">The method to invoke when the command is triggered. This method is passed the command name and arguments submitted by the user.</param> - /// <param name="allowNullCallback">Whether to allow a null <paramref name="callback"/> argument; this should only used for backwards compatibility.</param> /// <exception cref="ArgumentNullException">The <paramref name="name"/> or <paramref name="callback"/> is null or empty.</exception> /// <exception cref="FormatException">The <paramref name="name"/> is not a valid format.</exception> /// <exception cref="ArgumentException">There's already a command with that name.</exception> - public CommandManager Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) + public CommandManager Add(IModMetadata? mod, string name, string documentation, Action<string, string[]> callback) { - name = this.GetNormalizedName(name); + name = this.GetNormalizedName(name)!; // null-checked below // validate format if (string.IsNullOrWhiteSpace(name)) throw new ArgumentNullException(nameof(name), "Can't register a command with no name."); if (name.Any(char.IsWhiteSpace)) throw new FormatException($"Can't register the '{name}' command because the name can't contain whitespace."); - if (callback == null && !allowNullCallback) + if (callback == null) throw new ArgumentNullException(nameof(callback), $"Can't register the '{name}' command because without a callback."); // ensure uniqueness @@ -65,16 +65,19 @@ namespace StardewModdingAPI.Framework /// <exception cref="ArgumentException">There's already a command with that name.</exception> public CommandManager Add(IInternalCommand command, IMonitor monitor) { - return this.Add(null, command.Name, command.Description, (name, args) => command.HandleCommand(args, monitor)); + return this.Add(null, command.Name, command.Description, (_, args) => command.HandleCommand(args, monitor)); } /// <summary>Get a command by its unique name.</summary> /// <param name="name">The command name.</param> /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> - public Command Get(string name) + public Command? Get(string? name) { - name = this.GetNormalizedName(name); - this.Commands.TryGetValue(name, out Command command); + name = this.GetNormalizedName(name)!; + if (string.IsNullOrWhiteSpace(name)) + return null; + + this.Commands.TryGetValue(name, out Command? command); return command; } @@ -93,7 +96,7 @@ namespace StardewModdingAPI.Framework /// <param name="command">The command which can handle the input.</param> /// <param name="screenId">The screen ID on which to run the command.</param> /// <returns>Returns true if the input was successfully parsed and matched to a command; else false.</returns> - public bool TryParse(string input, out string name, out string[] args, out Command command, out int screenId) + public bool TryParse(string? input, [NotNullWhen(true)] out string? name, [NotNullWhen(true)] out string[]? args, [NotNullWhen(true)] out Command? command, out int screenId) { // ignore if blank if (string.IsNullOrWhiteSpace(input)) @@ -107,7 +110,7 @@ namespace StardewModdingAPI.Framework // parse input args = this.ParseArgs(input); - name = this.GetNormalizedName(args[0]); + name = this.GetNormalizedName(args[0])!; args = args.Skip(1).ToArray(); // get screen ID argument @@ -115,7 +118,7 @@ namespace StardewModdingAPI.Framework for (int i = 0; i < args.Length; i++) { // consume arg & set screen ID - if (this.TryParseScreenId(args[i], out int rawScreenId, out string error)) + if (this.TryParseScreenId(args[i], out int rawScreenId, out string? error)) { args = args.Take(i).Concat(args.Skip(i + 1)).ToArray(); screenId = rawScreenId; @@ -139,15 +142,15 @@ namespace StardewModdingAPI.Framework /// <param name="name">The command name.</param> /// <param name="arguments">The command arguments.</param> /// <returns>Returns whether a matching command was triggered.</returns> - public bool Trigger(string name, string[] arguments) + public bool Trigger(string? name, string[] arguments) { // get normalized name - name = this.GetNormalizedName(name); - if (name == null) + name = this.GetNormalizedName(name)!; + if (string.IsNullOrWhiteSpace(name)) return false; // get command - if (this.Commands.TryGetValue(name, out Command command)) + if (this.Commands.TryGetValue(name, out Command? command)) { command.Callback.Invoke(name, arguments); return true; @@ -166,7 +169,7 @@ namespace StardewModdingAPI.Framework { bool inQuotes = false; IList<string> args = new List<string>(); - StringBuilder currentArg = new StringBuilder(); + StringBuilder currentArg = new(); foreach (char ch in input) { if (ch == '"') @@ -190,7 +193,7 @@ namespace StardewModdingAPI.Framework /// <param name="screen">The parsed screen ID, if any.</param> /// <param name="error">The error which indicates an invalid screen ID, if applicable.</param> /// <returns>Returns whether the screen ID was parsed successfully.</returns> - private bool TryParseScreenId(string arg, out int screen, out string error) + private bool TryParseScreenId(string arg, out int screen, out string? error) { screen = -1; error = null; @@ -219,7 +222,7 @@ namespace StardewModdingAPI.Framework /// <summary>Get a normalized command name.</summary> /// <param name="name">The command name.</param> - private string GetNormalizedName(string name) + private string? GetNormalizedName(string? name) { name = name?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(name) diff --git a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs index 45b34556..6dc6f131 100644 --- a/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs +++ b/src/SMAPI/Framework/Commands/HarmonySummaryCommand.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.Commands { SearchResult[] matches = this.FilterPatches(args).OrderBy(p => p.MethodName).ToArray(); - StringBuilder result = new StringBuilder(); + StringBuilder result = new(); if (!matches.Any()) result.AppendLine("No current patches match your search."); @@ -71,9 +71,9 @@ namespace StardewModdingAPI.Framework.Commands private IEnumerable<SearchResult> FilterPatches(string[] searchTerms) { bool hasSearch = searchTerms.Any(); - bool IsMatch(string target) => !hasSearch || searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1); + bool IsMatch(string? target) => !hasSearch || searchTerms.Any(search => target != null && target.IndexOf(search, StringComparison.OrdinalIgnoreCase) > -1); - foreach (var patch in this.GetAllPatches()) + foreach (SearchResult patch in this.GetAllPatches()) { // matches entire patch if (IsMatch(patch.MethodDescription)) @@ -83,10 +83,10 @@ namespace StardewModdingAPI.Framework.Commands } // matches individual patchers - foreach (var pair in patch.PatchTypesByOwner.ToArray()) + foreach ((string patcherId, ISet<PatchType> patchTypes) in patch.PatchTypesByOwner.ToArray()) { - if (!IsMatch(pair.Key) && !pair.Value.Any(type => IsMatch(type.ToString()))) - patch.PatchTypesByOwner.Remove(pair.Key); + if (!IsMatch(patcherId) && !patchTypes.Any(type => IsMatch(type.ToString()))) + patch.PatchTypesByOwner.Remove(patcherId); } if (patch.PatchTypesByOwner.Any()) @@ -112,13 +112,13 @@ namespace StardewModdingAPI.Framework.Commands // get patch types by owner var typesByOwner = new Dictionary<string, ISet<PatchType>>(); - foreach (var group in patchGroups) + foreach ((PatchType type, IReadOnlyCollection<Patch> patches) in patchGroups) { - foreach (var patch in group.Value) + foreach (Patch patch in patches) { - if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType> patchTypes)) + if (!typesByOwner.TryGetValue(patch.owner, out ISet<PatchType>? patchTypes)) typesByOwner[patch.owner] = patchTypes = new HashSet<PatchType>(); - patchTypes.Add(group.Key); + patchTypes.Add(type); } } diff --git a/src/SMAPI/Framework/Commands/HelpCommand.cs b/src/SMAPI/Framework/Commands/HelpCommand.cs index baf3116e..65dc3bce 100644 --- a/src/SMAPI/Framework/Commands/HelpCommand.cs +++ b/src/SMAPI/Framework/Commands/HelpCommand.cs @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework.Commands { if (args.Any()) { - Command result = this.CommandManager.Get(args[0]); + Command? result = this.CommandManager.Get(args[0]); if (result == null) monitor.Log("There's no command with that name. Type 'help' by itself for more info.", LogLevel.Error); else @@ -61,10 +61,10 @@ namespace StardewModdingAPI.Framework.Commands + "--------------\n" + "The following commands are registered. For more info about a command, type 'help command_name'.\n\n"; - IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName).ToArray(); + IGrouping<string, string>[] groups = (from command in this.CommandManager.GetAll() orderby command.Mod?.DisplayName, command.Name group command.Name by command.Mod?.DisplayName ?? "SMAPI").ToArray(); foreach (var group in groups) { - string modName = group.Key ?? "SMAPI"; + string modName = group.Key; string[] commandNames = group.ToArray(); message += $"{modName}:\n {string.Join("\n ", commandNames)}\n\n"; } diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs index 5c90d83b..0367e999 100644 --- a/src/SMAPI/Framework/Content/AssetData.cs +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -5,12 +5,13 @@ namespace StardewModdingAPI.Framework.Content /// <summary>Base implementation for a content helper which encapsulates access and changes to content being read from a data file.</summary> /// <typeparam name="TValue">The interface value type.</typeparam> internal class AssetData<TValue> : AssetInfo, IAssetData<TValue> + where TValue : notnull { /********* ** Fields *********/ /// <summary>A callback to invoke when the data is replaced (if any).</summary> - private readonly Action<TValue> OnDataReplaced; + private readonly Action<TValue>? OnDataReplaced; /********* @@ -25,11 +26,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced) + public AssetData(string? locale, IAssetName assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue>? onDataReplaced) : base(locale, assetName, data.GetType(), getNormalizedPath) { this.Data = data; diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs index 26cbff5a..d9bfa7bf 100644 --- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs +++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs @@ -11,11 +11,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForDictionary(string locale, string assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) + public AssetDataForDictionary(string? locale, IAssetName assetName, IDictionary<TKey, TValue> data, Func<string, string> getNormalizedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } } } diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index 529fb93a..97729c95 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -21,11 +21,11 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) + public AssetDataForImage(string? locale, IAssetName assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// <inheritdoc /> @@ -41,39 +41,40 @@ namespace StardewModdingAPI.Framework.Content targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); // validate - if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) + if (!source.Bounds.Contains(sourceArea.Value)) throw new ArgumentOutOfRangeException(nameof(sourceArea), "The source area is outside the bounds of the source texture."); - if (targetArea.Value.X < 0 || targetArea.Value.Y < 0 || targetArea.Value.Right > target.Width || targetArea.Value.Bottom > target.Height) + if (!target.Bounds.Contains(targetArea.Value)) throw new ArgumentOutOfRangeException(nameof(targetArea), "The target area is outside the bounds of the target texture."); - if (sourceArea.Value.Width != targetArea.Value.Width || sourceArea.Value.Height != targetArea.Value.Height) + if (sourceArea.Value.Size != targetArea.Value.Size) throw new InvalidOperationException("The source and target areas must be the same size."); // get source data int pixelCount = sourceArea.Value.Width * sourceArea.Value.Height; - Color[] sourceData = new Color[pixelCount]; + Color[] sourceData = GC.AllocateUninitializedArray<Color>(pixelCount); source.GetData(0, sourceArea, sourceData, 0, pixelCount); // merge data in overlay mode if (patchMode == PatchMode.Overlay) { // get target data - Color[] targetData = new Color[pixelCount]; + Color[] targetData = GC.AllocateUninitializedArray<Color>(pixelCount); target.GetData(0, targetArea, targetData, 0, pixelCount); // merge pixels - Color[] newData = new Color[targetArea.Value.Width * targetArea.Value.Height]; - target.GetData(0, targetArea, newData, 0, newData.Length); for (int i = 0; i < sourceData.Length; i++) { Color above = sourceData[i]; Color below = targetData[i]; // shortcut transparency - if (above.A < AssetDataForImage.MinOpacity) + if (above.A < MinOpacity) + { + sourceData[i] = below; continue; - if (below.A < AssetDataForImage.MinOpacity) + } + if (below.A < MinOpacity) { - newData[i] = above; + sourceData[i] = above; continue; } @@ -84,14 +85,13 @@ namespace StardewModdingAPI.Framework.Content // Note: don't use named arguments here since they're different between // Linux/macOS and Windows. float alphaBelow = 1 - (above.A / 255f); - newData[i] = new Color( + sourceData[i] = new Color( (int)(above.R + (below.R * alphaBelow)), // r (int)(above.G + (below.G * alphaBelow)), // g (int)(above.B + (below.B * alphaBelow)), // b Math.Max(above.A, below.A) // a ); } - sourceData = newData; } // patch target texture @@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.Content return false; Texture2D original = this.Data; - Texture2D texture = new Texture2D(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); + Texture2D texture = new(Game1.graphics.GraphicsDevice, Math.Max(original.Width, minWidth), Math.Max(original.Height, minHeight)); this.ReplaceWith(texture); this.PatchImage(original); return true; diff --git a/src/SMAPI/Framework/Content/AssetDataForMap.cs b/src/SMAPI/Framework/Content/AssetDataForMap.cs index 0a5fa7e7..b8722ead 100644 --- a/src/SMAPI/Framework/Content/AssetDataForMap.cs +++ b/src/SMAPI/Framework/Content/AssetDataForMap.cs @@ -2,11 +2,14 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; +using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using xTile; +using xTile.Dimensions; using xTile.Layers; using xTile.Tiles; +using Rectangle = Microsoft.Xna.Framework.Rectangle; namespace StardewModdingAPI.Framework.Content { @@ -14,16 +17,27 @@ namespace StardewModdingAPI.Framework.Content internal class AssetDataForMap : AssetData<Map>, IAssetDataForMap { /********* + ** Fields + *********/ + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> - public AssetDataForMap(string locale, string assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced) - : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } + /// <param name="reflection">Simplifies access to private code.</param> + public AssetDataForMap(string? locale, IAssetName assetName, Map data, Func<string, string> getNormalizedPath, Action<Map> onDataReplaced, Reflector reflection) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) + { + this.Reflection = reflection; + } /// <inheritdoc /> /// <remarks>Derived from <see cref="GameLocation.ApplyMapOverride(Map,string,Rectangle?,Rectangle?)"/> with a few changes: @@ -85,8 +99,15 @@ namespace StardewModdingAPI.Framework.Content } // get target layers - IDictionary<Layer, Layer> sourceToTargetLayers = source.Layers.ToDictionary(p => p, p => target.GetLayer(p.Id)); - HashSet<Layer> orphanedTargetLayers = new HashSet<Layer>(target.Layers.Except(sourceToTargetLayers.Values)); + Dictionary<Layer, Layer> sourceToTargetLayers = + ( + from sourceLayer in source.Layers + let targetLayer = target.GetLayer(sourceLayer.Id) + where targetLayer != null + select (sourceLayer, targetLayer) + ) + .ToDictionary(p => p.sourceLayer, p => p.targetLayer); + HashSet<Layer> orphanedTargetLayers = new(target.Layers.Except(sourceToTargetLayers.Values)); // apply tiles bool replaceAll = patchMode == PatchMapMode.Replace; @@ -96,8 +117,8 @@ namespace StardewModdingAPI.Framework.Content for (int y = 0; y < sourceArea.Value.Height; y++) { // calculate tile positions - Point sourcePos = new Point(sourceArea.Value.X + x, sourceArea.Value.Y + y); - Point targetPos = new Point(targetArea.Value.X + x, targetArea.Value.Y + y); + Point sourcePos = new(sourceArea.Value.X + x, sourceArea.Value.Y + y); + Point targetPos = new(targetArea.Value.X + x, targetArea.Value.Y + y); // replace tiles on target-only layers if (replaceAll) @@ -110,8 +131,7 @@ namespace StardewModdingAPI.Framework.Content foreach (Layer sourceLayer in source.Layers) { // get layer - Layer targetLayer = sourceToTargetLayers[sourceLayer]; - if (targetLayer == null) + if (!sourceToTargetLayers.TryGetValue(sourceLayer, out Layer? targetLayer)) { target.AddLayer(targetLayer = new Layer(sourceLayer.Id, target, target.Layers[0].LayerSize, Layer.m_tileSize)); sourceToTargetLayers[sourceLayer] = target.GetLayer(sourceLayer.Id); @@ -121,11 +141,13 @@ namespace StardewModdingAPI.Framework.Content targetLayer.Properties.CopyFrom(sourceLayer.Properties); // create new tile - Tile sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y]; - Tile newTile = sourceTile != null - ? this.CreateTile(sourceTile, targetLayer, tilesheetMap[sourceTile.TileSheet]) - : null; - newTile?.Properties.CopyFrom(sourceTile.Properties); + Tile? sourceTile = sourceLayer.Tiles[sourcePos.X, sourcePos.Y]; + Tile? newTile = null; + if (sourceTile != null) + { + newTile = this.CreateTile(sourceTile, targetLayer, tilesheetMap[sourceTile.TileSheet]); + newTile?.Properties.CopyFrom(sourceTile.Properties); + } // replace tile if (newTile != null || replaceByLayer || replaceAll) @@ -135,6 +157,43 @@ namespace StardewModdingAPI.Framework.Content } } + /// <inheritdoc /> + public bool ExtendMap(int minWidth = 0, int minHeight = 0) + { + bool resized = false; + Map map = this.Data; + + // resize layers + foreach (Layer layer in map.Layers) + { + // check if resize needed + if (layer.LayerWidth >= minWidth && layer.LayerHeight >= minHeight) + continue; + resized = true; + + // build new tile matrix + int width = Math.Max(minWidth, layer.LayerWidth); + int height = Math.Max(minHeight, layer.LayerHeight); + Tile[,] tiles = new Tile[width, height]; + for (int x = 0; x < layer.LayerWidth; x++) + { + for (int y = 0; y < layer.LayerHeight; y++) + tiles[x, y] = layer.Tiles[x, y]; + } + + // update fields + this.Reflection.GetField<Tile[,]>(layer, "m_tiles").SetValue(tiles); + this.Reflection.GetField<TileArray>(layer, "m_tileArray").SetValue(new TileArray(layer, tiles)); + this.Reflection.GetField<Size>(layer, "m_layerSize").SetValue(new Size(width, height)); + } + + // resize map + if (resized) + this.Reflection.GetMethod(map, "UpdateDisplaySize").Invoke(); + + return resized; + } + /********* ** Private methods @@ -143,11 +202,11 @@ namespace StardewModdingAPI.Framework.Content /// <param name="sourceTile">The source tile to copy.</param> /// <param name="targetLayer">The target layer.</param> /// <param name="targetSheet">The target tilesheet.</param> - private Tile CreateTile(Tile sourceTile, Layer targetLayer, TileSheet targetSheet) + private Tile? CreateTile(Tile sourceTile, Layer targetLayer, TileSheet targetSheet) { switch (sourceTile) { - case StaticTile _: + case StaticTile: return new StaticTile(targetLayer, targetSheet, sourceTile.BlendMode, sourceTile.TileIndex); case AnimatedTile animatedTile: @@ -168,7 +227,7 @@ namespace StardewModdingAPI.Framework.Content } /// <summary>Normalize a map tilesheet path for comparison. This value should *not* be used as the actual tilesheet path.</summary> /// <param name="path">The path to normalize.</param> - private string NormalizeTilesheetPathForComparison(string path) + private string NormalizeTilesheetPathForComparison(string? path) { if (string.IsNullOrWhiteSpace(path)) return string.Empty; diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index b7e8dfeb..6c40f5f9 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; using xTile; namespace StardewModdingAPI.Framework.Content @@ -9,47 +10,61 @@ namespace StardewModdingAPI.Framework.Content internal class AssetDataForObject : AssetData<object>, IAssetData { /********* + ** Fields + *********/ + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + + + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> - public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalizedPath) - : base(locale, assetName, data, getNormalizedPath, onDataReplaced: null) { } + /// <param name="reflection">Simplifies access to private code.</param> + /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> + public AssetDataForObject(string? locale, IAssetName assetName, object data, Func<string, string> getNormalizedPath, Reflector reflection, Action<object>? onDataReplaced = null) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) + { + this.Reflection = reflection; + } /// <summary>Construct an instance.</summary> /// <param name="info">The asset metadata.</param> /// <param name="data">The content data being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> - public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath) - : this(info.Locale, info.AssetName, data, getNormalizedPath) { } + /// <param name="reflection">Simplifies access to private code.</param> + /// <param name="onDataReplaced">A callback to invoke when the data is replaced (if any).</param> + public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalizedPath, Reflector reflection, Action<object>? onDataReplaced = null) + : this(info.Locale, info.Name, data, getNormalizedPath, reflection, onDataReplaced) { } /// <inheritdoc /> public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() { - return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.Name, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); } /// <inheritdoc /> public IAssetDataForImage AsImage() { - return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForImage(this.Locale, this.Name, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } /// <inheritdoc /> public IAssetDataForMap AsMap() { - return new AssetDataForMap(this.Locale, this.AssetName, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith); + return new AssetDataForMap(this.Locale, this.Name, this.GetData<Map>(), this.GetNormalizedPath, this.ReplaceWith, this.Reflection); } /// <inheritdoc /> public TData GetData<TData>() { - if (!(this.Data is TData)) + if (this.Data is not TData data) throw new InvalidCastException($"The content data of type {this.Data.GetType().FullName} can't be converted to the requested {typeof(TData).FullName}."); - return (TData)this.Data; + return data; } } } diff --git a/src/SMAPI/Framework/Content/AssetEditOperation.cs b/src/SMAPI/Framework/Content/AssetEditOperation.cs new file mode 100644 index 00000000..464948b0 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetEditOperation.cs @@ -0,0 +1,41 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>An edit to apply to an asset when it's requested from the content pipeline.</summary> + internal class AssetEditOperation + { + /********* + ** Accessors + *********/ + /// <summary>The mod applying the edit.</summary> + public IModMetadata Mod { get; } + + /// <summary>If there are multiple edits that apply to the same asset, the priority with which this one should be applied.</summary> + public AssetEditPriority Priority { get; } + + /// <summary>The content pack on whose behalf the edit is being applied, if any.</summary> + public IModMetadata? OnBehalfOf { get; } + + /// <summary>Apply the edit to an asset.</summary> + public Action<IAssetData> ApplyEdit { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod applying the edit.</param> + /// <param name="priority">If there are multiple edits that apply to the same asset, the priority with which this one should be applied.</param> + /// <param name="onBehalfOf">The content pack on whose behalf the edit is being applied, if any.</param> + /// <param name="applyEdit">Apply the edit to an asset.</param> + public AssetEditOperation(IModMetadata mod, AssetEditPriority priority, IModMetadata? onBehalfOf, Action<IAssetData> applyEdit) + { + this.Mod = mod; + this.Priority = priority; + this.OnBehalfOf = onBehalfOf; + this.ApplyEdit = applyEdit; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index d8106439..363fffb3 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Deprecations; namespace StardewModdingAPI.Framework.Content { @@ -17,10 +18,35 @@ namespace StardewModdingAPI.Framework.Content ** Accessors *********/ /// <inheritdoc /> - public string Locale { get; } + public string? Locale { get; } /// <inheritdoc /> - public string AssetName { get; } + public IAssetName Name { get; } + + /// <inheritdoc /> + public IAssetName NameWithoutLocale { get; } + + /// <inheritdoc /> + [Obsolete($"Use {nameof(AssetInfo.Name)} or {nameof(AssetInfo.NameWithoutLocale)} instead. This property will be removed in SMAPI 4.0.0.")] + public string AssetName + { + get + { + SCore.DeprecationManager.Warn( + source: SCore.DeprecationManager.GetModFromStack(), + nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetName)}", + version: "3.14.0", + severity: DeprecationLevel.Notice, + unlessStackIncludes: new[] + { + $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", + $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}" + } + ); + + return this.NameWithoutLocale.Name; + } + } /// <inheritdoc /> public Type DataType { get; } @@ -31,22 +57,36 @@ namespace StardewModdingAPI.Framework.Content *********/ /// <summary>Construct an instance.</summary> /// <param name="locale">The content's locale code, if the content is localized.</param> - /// <param name="assetName">The normalized asset name being read.</param> + /// <param name="assetName">The asset name being read.</param> /// <param name="type">The content type being read.</param> /// <param name="getNormalizedPath">Normalizes an asset key to match the cache key.</param> - public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalizedPath) + public AssetInfo(string? locale, IAssetName assetName, Type type, Func<string, string> getNormalizedPath) { this.Locale = locale; - this.AssetName = assetName; + this.Name = assetName; + this.NameWithoutLocale = assetName.GetBaseAssetName(); this.DataType = type; this.GetNormalizedPath = getNormalizedPath; } /// <inheritdoc /> + [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} or {nameof(AssetInfo.NameWithoutLocale)}.{nameof(IAssetName.IsEquivalentTo)} instead. This method will be removed in SMAPI 4.0.0.")] public bool AssetNameEquals(string path) { - path = this.GetNormalizedPath(path); - return this.AssetName.Equals(path, StringComparison.OrdinalIgnoreCase); + SCore.DeprecationManager.Warn( + source: SCore.DeprecationManager.GetModFromStack(), + nounPhrase: $"{nameof(IAssetInfo)}.{nameof(IAssetInfo.AssetNameEquals)}", + version: "3.14.0", + severity: DeprecationLevel.Notice, + unlessStackIncludes: new[] + { + $"{typeof(AssetInterceptorChange).FullName}.{nameof(AssetInterceptorChange.CanIntercept)}", + $"{typeof(ContentCoordinator).FullName}.{nameof(ContentCoordinator.GetAssetOperations)}" + } + ); + + + return this.NameWithoutLocale.IsEquivalentTo(path); } @@ -75,7 +115,7 @@ namespace StardewModdingAPI.Framework.Content return "string"; // default - return type.FullName; + return type.FullName!; } } } diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs index 10488b84..fc8199e8 100644 --- a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs +++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs @@ -2,6 +2,7 @@ using System; using System.Reflection; using StardewModdingAPI.Internal; +#pragma warning disable CS0618 // obsolete asset interceptors deliberately supported here namespace StardewModdingAPI.Framework.Content { /// <summary>A wrapper for <see cref="IAssetEditor"/> and <see cref="IAssetLoader"/> for internal cache invalidation.</summary> @@ -36,7 +37,7 @@ namespace StardewModdingAPI.Framework.Content this.Instance = instance ?? throw new ArgumentNullException(nameof(instance)); this.WasAdded = wasAdded; - if (!(instance is IAssetEditor) && !(instance is IAssetLoader)) + if (instance is not (IAssetEditor or IAssetLoader)) throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance."); } @@ -44,11 +45,11 @@ namespace StardewModdingAPI.Framework.Content /// <param name="asset">Basic metadata about the asset being loaded.</param> public bool CanIntercept(IAssetInfo asset) { - MethodInfo canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic); + MethodInfo? canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic); if (canIntercept == null) throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation."); - return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset }); + return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset })!; } @@ -70,7 +71,7 @@ namespace StardewModdingAPI.Framework.Content } catch (Exception ex) { - this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } } @@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.Content } catch (Exception ex) { - this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.Name}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } } diff --git a/src/SMAPI/Framework/Content/AssetLoadOperation.cs b/src/SMAPI/Framework/Content/AssetLoadOperation.cs new file mode 100644 index 00000000..b6cdec27 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetLoadOperation.cs @@ -0,0 +1,41 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>An operation which provides the initial instance of an asset when it's requested from the content pipeline.</summary> + internal class AssetLoadOperation + { + /********* + ** Accessors + *********/ + /// <summary>The mod loading the asset.</summary> + public IModMetadata Mod { get; } + + /// <summary>The content pack on whose behalf the asset is being loaded, if any.</summary> + public IModMetadata? OnBehalfOf { get; } + + /// <summary>If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</summary> + public AssetLoadPriority Priority { get; } + + /// <summary>Load the initial value for an asset.</summary> + public Func<IAssetInfo, object> GetData { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod applying the edit.</param> + /// <param name="priority">If there are multiple loads that apply to the same asset, the priority with which this one should be applied.</param> + /// <param name="onBehalfOf">The content pack on whose behalf the asset is being loaded, if any.</param> + /// <param name="getData">Load the initial value for an asset.</param> + public AssetLoadOperation(IModMetadata mod, AssetLoadPriority priority, IModMetadata? onBehalfOf, Func<IAssetInfo, object> getData) + { + this.Mod = mod; + this.Priority = priority; + this.OnBehalfOf = onBehalfOf; + this.GetData = getData; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetName.cs b/src/SMAPI/Framework/Content/AssetName.cs new file mode 100644 index 00000000..148354a1 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetName.cs @@ -0,0 +1,199 @@ +using System; +using StardewModdingAPI.Toolkit.Utilities; +using StardewValley; + +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>An asset name that can be loaded through the content pipeline.</summary> + internal class AssetName : IAssetName + { + /********* + ** Fields + *********/ + /// <summary>A lowercase version of <see cref="Name"/> used for consistent hash codes and equality checks.</summary> + private readonly string ComparableName; + + + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public string Name { get; } + + /// <inheritdoc /> + public string BaseName { get; } + + /// <inheritdoc /> + public string? LocaleCode { get; } + + /// <inheritdoc /> + public LocalizedContentManager.LanguageCode? LanguageCode { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="baseName">The base asset name without the locale code.</param> + /// <param name="localeCode">The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</param> + /// <param name="languageCode">The language code matching the <see cref="LocaleCode"/>, if applicable.</param> + public AssetName(string baseName, string? localeCode, LocalizedContentManager.LanguageCode? languageCode) + { + // validate + if (string.IsNullOrWhiteSpace(baseName)) + throw new ArgumentException("The asset name can't be null or empty.", nameof(baseName)); + if (string.IsNullOrWhiteSpace(localeCode)) + localeCode = null; + + // set base values + this.BaseName = PathUtilities.NormalizeAssetName(baseName); + this.LocaleCode = localeCode; + this.LanguageCode = languageCode; + + // set derived values + this.Name = localeCode != null + ? string.Concat(this.BaseName, '.', this.LocaleCode) + : this.BaseName; + this.ComparableName = this.Name.ToLowerInvariant(); + } + + /// <summary>Parse a raw asset name into an instance.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <param name="parseLocale">Get the language code for a given locale, if it's valid.</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + public static AssetName Parse(string rawName, Func<string, LocalizedContentManager.LanguageCode?> parseLocale) + { + if (string.IsNullOrWhiteSpace(rawName)) + throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); + + string baseName = rawName; + string? localeCode = null; + LocalizedContentManager.LanguageCode? languageCode = null; + + int lastPeriodIndex = rawName.LastIndexOf('.'); + if (lastPeriodIndex > 0 && rawName.Length > lastPeriodIndex + 1) + { + string possibleLocaleCode = rawName[(lastPeriodIndex + 1)..]; + LocalizedContentManager.LanguageCode? possibleLanguageCode = parseLocale(possibleLocaleCode); + + if (possibleLanguageCode != null) + { + baseName = rawName[..lastPeriodIndex]; + localeCode = possibleLocaleCode; + languageCode = possibleLanguageCode; + } + } + + return new AssetName(baseName, localeCode, languageCode); + } + + /// <inheritdoc /> + public bool IsEquivalentTo(string? assetName, bool useBaseName = false) + { + // empty asset key is never equivalent + if (string.IsNullOrWhiteSpace(assetName)) + return false; + + assetName = PathUtilities.NormalizeAssetName(assetName); + + string compareTo = useBaseName ? this.BaseName : this.Name; + return compareTo.Equals(assetName, StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool IsEquivalentTo(IAssetName? assetName, bool useBaseName = false) + { + if (useBaseName) + return this.BaseName.Equals(assetName?.BaseName, StringComparison.OrdinalIgnoreCase); + + if (assetName is AssetName impl) + return this.ComparableName == impl.ComparableName; + + return this.Name.Equals(assetName?.Name, StringComparison.OrdinalIgnoreCase); + } + + /// <inheritdoc /> + public bool StartsWith(string? prefix, bool allowPartialWord = true, bool allowSubfolder = true) + { + // asset keys never start with null + if (prefix is null) + return false; + + string rawTrimmed = prefix.Trim(); + + // asset keys can't have a leading slash, but NormalizeAssetName will trim them + if (rawTrimmed.StartsWith('/') || rawTrimmed.StartsWith('\\')) + return false; + + // normalize prefix + { + string normalized = PathUtilities.NormalizeAssetName(prefix); + + // keep trailing slash + if (rawTrimmed.EndsWith('/') || rawTrimmed.EndsWith('\\')) + normalized += PathUtilities.PreferredAssetSeparator; + + prefix = normalized; + } + + // compare + if (prefix.Length == 0) + return true; + + return + this.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && ( + allowPartialWord + || this.Name.Length == prefix.Length + || !char.IsLetterOrDigit(prefix[^1]) // last character in suffix is word separator + || !char.IsLetterOrDigit(this.Name[prefix.Length]) // or first character after it is + ) + && ( + allowSubfolder + || this.Name.Length == prefix.Length + || !this.Name[prefix.Length..].Contains(PathUtilities.PreferredAssetSeparator) + ); + } + + + /// <inheritdoc /> + public bool IsDirectlyUnderPath(string? assetFolder) + { + if (assetFolder is null) + return false; + + return this.StartsWith(assetFolder + "/", allowPartialWord: false, allowSubfolder: false); + } + + /// <inheritdoc /> + IAssetName IAssetName.GetBaseAssetName() + { + return this.LocaleCode == null + ? this + : new AssetName(this.BaseName, null, null); + } + + /// <inheritdoc /> + public bool Equals(IAssetName? other) + { + return other switch + { + null => false, + AssetName otherImpl => this.ComparableName == otherImpl.ComparableName, + _ => StringComparer.OrdinalIgnoreCase.Equals(this.Name, other.Name) + }; + } + + /// <inheritdoc /> + public override int GetHashCode() + { + return this.ComparableName.GetHashCode(); + } + + /// <inheritdoc /> + public override string ToString() + { + return this.Name; + } + } +} diff --git a/src/SMAPI/Framework/Content/AssetOperationGroup.cs b/src/SMAPI/Framework/Content/AssetOperationGroup.cs new file mode 100644 index 00000000..a2fcb722 --- /dev/null +++ b/src/SMAPI/Framework/Content/AssetOperationGroup.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Framework.Content +{ + /// <summary>A set of operations to apply to an asset for a given <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> implementation.</summary> + internal class AssetOperationGroup + { + /********* + ** Accessors + *********/ + /// <summary>The mod applying the changes.</summary> + public IModMetadata Mod { get; } + + /// <summary>The load operations to apply.</summary> + public AssetLoadOperation[] LoadOperations { get; } + + /// <summary>The edit operations to apply.</summary> + public AssetEditOperation[] EditOperations { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod applying the changes.</param> + /// <param name="loadOperations">The load operations to apply.</param> + /// <param name="editOperations">The edit operations to apply.</param> + public AssetOperationGroup(IModMetadata mod, AssetLoadOperation[] loadOperations, AssetEditOperation[] editOperations) + { + this.Mod = mod; + this.LoadOperations = loadOperations; + this.EditOperations = editOperations; + } + } +} diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 8e0c6228..736ee5da 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -1,10 +1,9 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Linq; -using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Toolkit.Utilities; -using StardewValley; namespace StardewModdingAPI.Framework.Content { @@ -40,11 +39,10 @@ namespace StardewModdingAPI.Framework.Content ** Constructor ****/ /// <summary>Construct an instance.</summary> - /// <param name="contentManager">The underlying content manager whose cache to manage.</param> - /// <param name="reflection">Simplifies access to private game code.</param> - public ContentCache(LocalizedContentManager contentManager, Reflector reflection) + /// <param name="loadedAssets">The asset cache for the underlying content manager.</param> + public ContentCache(Dictionary<string, object> loadedAssets) { - this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue(); + this.Cache = loadedAssets; } /**** @@ -64,7 +62,8 @@ namespace StardewModdingAPI.Framework.Content /// <summary>Normalize path separators in an asset name.</summary> /// <param name="path">The file path to normalize.</param> [Pure] - public string NormalizePathSeparators(string path) + [return: NotNullIfNotNull("path")] + public string? NormalizePathSeparators(string? path) { return PathUtilities.NormalizeAssetName(path); } @@ -77,7 +76,7 @@ namespace StardewModdingAPI.Framework.Content { key = this.NormalizePathSeparators(key); return key.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase) - ? key.Substring(0, key.Length - 4) + ? key[..^4] : key; } @@ -91,7 +90,7 @@ namespace StardewModdingAPI.Framework.Content public bool Remove(string key, bool dispose) { // get entry - if (!this.Cache.TryGetValue(key, out object value)) + if (!this.Cache.TryGetValue(key, out object? value)) return false; // dispose & remove entry diff --git a/src/SMAPI/Framework/Content/TilesheetReference.cs b/src/SMAPI/Framework/Content/TilesheetReference.cs index 0919bb44..0339b802 100644 --- a/src/SMAPI/Framework/Content/TilesheetReference.cs +++ b/src/SMAPI/Framework/Content/TilesheetReference.cs @@ -1,4 +1,3 @@ -using System.Numerics; using xTile.Dimensions; namespace StardewModdingAPI.Framework.Content diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index 99091f3e..84fff250 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -7,13 +7,17 @@ using System.Linq; using System.Text; using System.Threading; using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Framework.Utilities; +using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using StardewValley.GameData; using xTile; namespace StardewModdingAPI.Framework @@ -45,27 +49,36 @@ namespace StardewModdingAPI.Framework /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> private readonly Action OnLoadingFirstAsset; - /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> - private readonly IList<IContentManager> ContentManagers = new List<IContentManager>(); + /// <summary>A callback to invoke when an asset is fully loaded.</summary> + private readonly Action<BaseContentManager, IAssetName> OnAssetLoaded; + + /// <summary>A callback to invoke when any asset names have been invalidated from the cache.</summary> + private readonly Action<IList<IAssetName>> OnAssetsInvalidated; + + /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary> + private readonly Func<IAssetInfo, IList<AssetOperationGroup>> RequestAssetOperations; - /// <summary>The language code for language-agnostic mod assets.</summary> - private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>The loaded content managers (including the <see cref="MainContentManager"/>).</summary> + private readonly List<IContentManager> ContentManagers = new(); /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; /// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary> /// <remarks>The game may add content managers in asynchronous threads (e.g. when populating the load screen).</remarks> - private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim(); + private readonly ReaderWriterLockSlim ContentManagerLock = new(); /// <summary>A cache of ordered tilesheet IDs used by vanilla maps.</summary> - private readonly IDictionary<string, TilesheetReference[]> VanillaTilesheets = new Dictionary<string, TilesheetReference[]>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, TilesheetReference[]?> VanillaTilesheets = new(StringComparer.OrdinalIgnoreCase); /// <summary>An unmodified content manager which doesn't intercept assets, used to compare asset data.</summary> private readonly LocalizedContentManager VanillaContentManager; /// <summary>The language enum values indexed by locale code.</summary> - private Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; + private Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>> LocaleCodes; + + /// <summary>The cached asset load/edit operations to apply, indexed by asset name.</summary> + private readonly TickCacheDictionary<IAssetName, AssetOperationGroup[]> AssetOperationsByKey = new(); /********* @@ -78,9 +91,11 @@ namespace StardewModdingAPI.Framework public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language; /// <summary>Interceptors which provide the initial versions of matching assets.</summary> + [Obsolete] public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>(); /// <summary>Interceptors which edit matching assets after they're loaded.</summary> + [Obsolete] public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>(); /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> @@ -98,15 +113,21 @@ namespace StardewModdingAPI.Framework /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + /// <param name="onAssetLoaded">A callback to invoke when an asset is fully loaded.</param> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) + /// <param name="onAssetsInvalidated">A callback to invoke when any asset names have been invalidated from the cache.</param> + /// <param name="requestAssetOperations">Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</param> + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations, Action<IList<IAssetName>> onAssetsInvalidated, Func<IAssetInfo, IList<AssetOperationGroup>> requestAssetOperations) { this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.Reflection = reflection; this.JsonHelper = jsonHelper; this.OnLoadingFirstAsset = onLoadingFirstAsset; - this.FullRootDirectory = Path.Combine(Constants.ExecutionPath, rootDirectory); + this.OnAssetLoaded = onAssetLoaded; + this.OnAssetsInvalidated = onAssetsInvalidated; + this.RequestAssetOperations = requestAssetOperations; + this.FullRootDirectory = Path.Combine(Constants.GamePath, rootDirectory); this.ContentManagers.Add( this.MainContentManager = new GameContentManager( name: "Game1.content", @@ -118,6 +139,7 @@ namespace StardewModdingAPI.Framework reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, + onAssetLoaded: onAssetLoaded, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ) ); @@ -131,12 +153,13 @@ namespace StardewModdingAPI.Framework reflection: reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: onLoadingFirstAsset, + onAssetLoaded: onAssetLoaded, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations ); this.ContentManagers.Add(contentManagerForAssetPropagation); this.VanillaContentManager = new LocalizedContentManager(serviceProvider, rootDirectory); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations); - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager, contentManagerForAssetPropagation, this.Monitor, reflection, aggressiveMemoryOptimizations, name => this.ParseAssetName(name, allowLocales: true)); + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages: Enumerable.Empty<ModLanguage>())); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> @@ -145,7 +168,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - GameContentManager manager = new GameContentManager( + GameContentManager manager = new( name: name, serviceProvider: this.MainContentManager.ServiceProvider, rootDirectory: this.MainContentManager.RootDirectory, @@ -155,6 +178,7 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, onDisposing: this.OnDisposing, onLoadingFirstAsset: this.OnLoadingFirstAsset, + onAssetLoaded: this.OnAssetLoaded, aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations ); this.ContentManagers.Add(manager); @@ -171,7 +195,7 @@ namespace StardewModdingAPI.Framework { return this.ContentManagerLock.InWriteLock(() => { - ModContentManager manager = new ModContentManager( + ModContentManager manager = new( name: name, gameContentManager: gameContentManager, serviceProvider: this.MainContentManager.ServiceProvider, @@ -183,7 +207,8 @@ namespace StardewModdingAPI.Framework reflection: this.Reflection, jsonHelper: this.JsonHelper, onDisposing: this.OnDisposing, - aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations + aggressiveMemoryOptimizations: this.AggressiveMemoryOptimizations, + relativePathCache: CaseInsensitivePathLookup.GetCachedFor(rootDirectory) ); this.ContentManagers.Add(manager); return manager; @@ -196,18 +221,21 @@ namespace StardewModdingAPI.Framework return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } - /// <summary>Perform any cleanup needed when the locale changes.</summary> - public void OnLocaleChanged() + /// <summary>Perform any updates needed when the game loads custom languages from <c>Data/AdditionalLanguages</c>.</summary> + public void OnAdditionalLanguagesInitialized() { - // rebuild locale cache (which may change due to custom mod languages) - this.LocaleCodes = new Lazy<IDictionary<string, LocalizedContentManager.LanguageCode>>(this.GetLocaleCodes); + // update locale cache for custom languages, and load it now (since languages added later won't work) + var customLanguages = this.MainContentManager.Load<List<ModLanguage?>>("Data/AdditionalLanguages"); + this.LocaleCodes = new Lazy<Dictionary<string, LocalizedContentManager.LanguageCode>>(() => this.GetLocaleCodes(customLanguages)); + _ = this.LocaleCodes.Value; + } - // reload affected content + /// <summary>Perform any updates needed when the locale changes.</summary> + public void OnLocaleChanged() + { + // reset baseline cache this.ContentManagerLock.InReadLock(() => { - foreach (IContentManager contentManager in this.ContentManagers) - contentManager.OnLocaleChanged(); - this.VanillaContentManager.Unload(); }); } @@ -239,12 +267,28 @@ namespace StardewModdingAPI.Framework // Note that we *must* propagate changes here, otherwise when mods invalidate the cache later to reapply // their changes, the assets won't be found in the cache so no changes will be propagated. if (LocalizedContentManager.CurrentLanguageCode != LocalizedContentManager.LanguageCode.en) - this.InvalidateCache((contentManager, key, type) => contentManager is GameContentManager); + this.InvalidateCache((contentManager, _, _) => contentManager is GameContentManager); + } + + /// <summary>Parse a raw asset name.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <param name="allowLocales">Whether to parse locales in the <paramref name="rawName"/>. If this is false, any locale codes in the name are treated as if they were part of the base name (e.g. for mod files).</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + public AssetName ParseAssetName(string rawName, bool allowLocales) + { + return !string.IsNullOrWhiteSpace(rawName) + ? AssetName.Parse( + rawName: rawName, + parseLocale: allowLocales + ? locale => this.LocaleCodes.Value.TryGetValue(locale, out LocalizedContentManager.LanguageCode langCode) ? langCode : null + : _ => null + ) + : throw new ArgumentException("The asset name can't be null or empty.", nameof(rawName)); } /// <summary>Get whether this asset is mapped to a mod folder.</summary> - /// <param name="key">The asset key.</param> - public bool IsManagedAssetKey(string key) + /// <param name="key">The asset name.</param> + public bool IsManagedAssetKey(IAssetName key) { return key.StartsWith(this.ManagedPrefix); } @@ -252,9 +296,9 @@ namespace StardewModdingAPI.Framework /// <summary>Parse a managed SMAPI asset key which maps to a mod folder.</summary> /// <param name="key">The asset key.</param> /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The relative path within the mod folder.</param> + /// <param name="relativePath">The asset name within the mod folder.</param> /// <returns>Returns whether the asset was parsed successfully.</returns> - public bool TryParseManagedAssetKey(string key, out string contentManagerID, out string relativePath) + public bool TryParseManagedAssetKey(string key, [NotNullWhen(true)] out string? contentManagerID, [NotNullWhen(true)] out IAssetName? relativePath) { contentManagerID = null; relativePath = null; @@ -268,7 +312,7 @@ namespace StardewModdingAPI.Framework if (parts.Length != 3) // managed key prefix, mod id, relative path return false; contentManagerID = Path.Combine(parts[0], parts[1]); - relativePath = parts[2]; + relativePath = this.ParseAssetName(parts[2], allowLocales: false); return true; } @@ -279,32 +323,52 @@ namespace StardewModdingAPI.Framework return Path.Combine(this.ManagedPrefix, modID.ToLower()); } + /// <summary>Get whether an asset from a mod folder exists.</summary> + /// <typeparam name="T">The expected asset type.</typeparam> + /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> + /// <param name="assetName">The asset name within the mod folder.</param> + public bool DoesManagedAssetExist<T>(string contentManagerID, IAssetName assetName) + where T : notnull + { + // get content manager + IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => + this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) + ); + if (contentManager == null) + throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); + + // get whether the asset exists + return contentManager.DoesAssetExist<T>(assetName); + } + /// <summary>Get a copy of an asset from a mod folder.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="contentManagerID">The unique name for the content manager which should load this asset.</param> - /// <param name="relativePath">The internal SMAPI asset key.</param> - public T LoadManagedAsset<T>(string contentManagerID, string relativePath) + /// <param name="relativePath">The asset name within the mod folder.</param> + public T LoadManagedAsset<T>(string contentManagerID, IAssetName relativePath) + where T : notnull { // get content manager - IContentManager contentManager = this.ContentManagerLock.InReadLock(() => + IContentManager? contentManager = this.ContentManagerLock.InReadLock(() => this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID) ); if (contentManager == null) throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod."); // get fresh asset - return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false); + return contentManager.LoadExact<T>(relativePath, useCache: false); } /// <summary>Purge matched assets from the cache.</summary> /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset keys.</returns> - public IEnumerable<string> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IAssetInfo, bool> predicate, bool dispose = false) { string locale = this.GetLocale(); - return this.InvalidateCache((contentManager, assetName, type) => + return this.InvalidateCache((_, rawName, type) => { + IAssetName assetName = this.ParseAssetName(rawName, allowLocales: true); IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); @@ -314,19 +378,20 @@ namespace StardewModdingAPI.Framework /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset names.</returns> - public IEnumerable<string> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) + public IEnumerable<IAssetName> InvalidateCache(Func<IContentManager, string, Type, bool> predicate, bool dispose = false) { // invalidate cache & track removed assets - IDictionary<string, Type> removedAssets = new Dictionary<string, Type>(StringComparer.OrdinalIgnoreCase); + IDictionary<IAssetName, Type> invalidatedAssets = new Dictionary<IAssetName, Type>(); this.ContentManagerLock.InReadLock(() => { // cached assets foreach (IContentManager contentManager in this.ContentManagers) { - foreach (var entry in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) + foreach ((string key, object asset) in contentManager.InvalidateCache((key, type) => predicate(contentManager, key, type), dispose)) { - if (!removedAssets.ContainsKey(entry.Key)) - removedAssets[entry.Key] = entry.Value.GetType(); + AssetName assetName = this.ParseAssetName(key, allowLocales: true); + if (!invalidatedAssets.ContainsKey(assetName)) + invalidatedAssets[assetName] = asset.GetType(); } } @@ -340,31 +405,38 @@ namespace StardewModdingAPI.Framework continue; // get map path - string mapPath = this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value); - if (!removedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath, typeof(Map))) - removedAssets[mapPath] = typeof(Map); + AssetName mapPath = this.ParseAssetName(this.MainContentManager.AssertAndNormalizeAssetName(location.mapPath.Value), allowLocales: true); + if (!invalidatedAssets.ContainsKey(mapPath) && predicate(this.MainContentManager, mapPath.Name, typeof(Map))) + invalidatedAssets[mapPath] = typeof(Map); } } }); - // reload core game assets - if (removedAssets.Any()) + // handle invalidation + if (invalidatedAssets.Any()) { + // clear cached editor checks + foreach (IAssetName name in invalidatedAssets.Keys) + this.AssetOperationsByKey.Remove(name); + + // raise event + this.OnAssetsInvalidated(invalidatedAssets.Keys.ToArray()); + // propagate changes to the game this.CoreAssets.Propagate( - assets: removedAssets.ToDictionary(p => p.Key, p => p.Value), + assets: invalidatedAssets.ToDictionary(p => p.Key, p => p.Value), ignoreWorld: Context.IsWorldFullyUnloaded, - out IDictionary<string, bool> propagated, + out IDictionary<IAssetName, bool> propagated, out bool updatedNpcWarps ); // log summary - StringBuilder report = new StringBuilder(); + StringBuilder report = new(); { - string[] invalidatedKeys = removedAssets.Keys.ToArray(); - string[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); + IAssetName[] invalidatedKeys = invalidatedAssets.Keys.ToArray(); + IAssetName[] propagatedKeys = propagated.Where(p => p.Value).Select(p => p.Key).ToArray(); - string FormatKeyList(IEnumerable<string> keys) => string.Join(", ", keys.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + string FormatKeyList(IEnumerable<IAssetName> keys) => string.Join(", ", keys.Select(p => p.Name).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); report.AppendLine($"Invalidated {invalidatedKeys.Length} asset names ({FormatKeyList(invalidatedKeys)})."); report.AppendLine(propagated.Count > 0 @@ -379,20 +451,32 @@ namespace StardewModdingAPI.Framework else this.Monitor.Log("Invalidated 0 cache entries."); - return removedAssets.Keys; + return invalidatedAssets.Keys; + } + + /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The asset info to load or edit.</param> + public IEnumerable<AssetOperationGroup> GetAssetOperations<T>(IAssetInfo info) + where T : notnull + { + return this.AssetOperationsByKey.GetOrSet( + info.Name, + () => this.GetAssetOperationsWithoutCache<T>(info).ToArray() + ); } /// <summary>Get all loaded instances of an asset name.</summary> /// <param name="assetName">The asset name.</param> [SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")] - public IEnumerable<object> GetLoadedValues(string assetName) + public IEnumerable<object> GetLoadedValues(IAssetName assetName) { return this.ContentManagerLock.InReadLock(() => { List<object> values = new List<object>(); - foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName, p.Language))) + foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName))) { - object value = content.Load<object>(assetName, this.Language, useCache: true); + object value = content.LoadExact<object>(assetName, useCache: true); values.Add(value); } return values; @@ -403,9 +487,9 @@ namespace StardewModdingAPI.Framework /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public TilesheetReference[] GetVanillaTilesheetIds(string assetName) { - if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[] tilesheets)) + if (!this.VanillaTilesheets.TryGetValue(assetName, out TilesheetReference[]? tilesheets)) { - tilesheets = this.TryLoadVanillaAsset(assetName, out Map map) + tilesheets = this.TryLoadVanillaAsset(assetName, out Map? map) ? map.TileSheets.Select((sheet, index) => new TilesheetReference(index, sheet.Id, sheet.ImageSource, sheet.SheetSize, sheet.TileSize)).ToArray() : null; @@ -416,23 +500,14 @@ namespace StardewModdingAPI.Framework return tilesheets ?? Array.Empty<TilesheetReference>(); } - /// <summary>Get the language enum which corresponds to a locale code (e.g. <see cref="LocalizedContentManager.LanguageCode.fr"/> given <c>fr-FR</c>).</summary> - /// <param name="locale">The locale code to search. This must exactly match the language; no fallback is performed.</param> - /// <param name="language">The matched language enum, if any.</param> - /// <returns>Returns whether a valid language was found.</returns> - public bool TryGetLanguageEnum(string locale, out LocalizedContentManager.LanguageCode language) - { - return this.LocaleCodes.Value.TryGetValue(locale, out language); - } - /// <summary>Get the locale code which corresponds to a language enum (e.g. <c>fr-FR</c> given <see cref="LocalizedContentManager.LanguageCode.fr"/>).</summary> /// <param name="language">The language enum to search.</param> - public string GetLocaleCode(LocalizedContentManager.LanguageCode language) + public string? GetLocaleCode(LocalizedContentManager.LanguageCode language) { if (language == LocalizedContentManager.LanguageCode.mod && LocalizedContentManager.CurrentModLanguage == null) return null; - return Game1.content.LanguageCodeString(language); + return this.MainContentManager.LanguageCodeString(language); } /// <summary>Dispose held resources.</summary> @@ -446,7 +521,7 @@ namespace StardewModdingAPI.Framework foreach (IContentManager contentManager in this.ContentManagers) contentManager.Dispose(); this.ContentManagers.Clear(); - this.MainContentManager = null; + this.MainContentManager = null!; // instance no longer usable this.ContentManagerLock.Dispose(); } @@ -471,7 +546,8 @@ namespace StardewModdingAPI.Framework /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="asset">The loaded asset data.</param> - private bool TryLoadVanillaAsset<T>(string assetName, out T asset) + private bool TryLoadVanillaAsset<T>(string assetName, [NotNullWhen(true)] out T? asset) + where T : notnull { try { @@ -486,17 +562,186 @@ namespace StardewModdingAPI.Framework } /// <summary>Get the language enums (like <see cref="LocalizedContentManager.LanguageCode.ja"/>) indexed by locale code (like <c>ja-JP</c>).</summary> - private IDictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes() + /// <param name="customLanguages">The custom languages to add to the lookup.</param> + private Dictionary<string, LocalizedContentManager.LanguageCode> GetLocaleCodes(IEnumerable<ModLanguage?> customLanguages) { - IDictionary<string, LocalizedContentManager.LanguageCode> map = new Dictionary<string, LocalizedContentManager.LanguageCode>(); + var map = new Dictionary<string, LocalizedContentManager.LanguageCode>(StringComparer.OrdinalIgnoreCase); + + // custom languages + foreach (ModLanguage? language in customLanguages) + { + if (!string.IsNullOrWhiteSpace(language?.LanguageCode)) + map[language.LanguageCode] = LocalizedContentManager.LanguageCode.mod; + } + + // vanilla languages (override custom language if they conflict) foreach (LocalizedContentManager.LanguageCode code in Enum.GetValues(typeof(LocalizedContentManager.LanguageCode))) { - string locale = this.GetLocaleCode(code); + string? locale = this.GetLocaleCode(code); if (locale != null) map[locale] = code; } return map; } + + /// <summary>Get the asset load and edit operations to apply to a given asset if it's (re)loaded now, ignoring the <see cref="AssetOperationsByKey"/> cache.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The asset info to load or edit.</param> + private IEnumerable<AssetOperationGroup> GetAssetOperationsWithoutCache<T>(IAssetInfo info) + where T : notnull + { + IAssetInfo legacyInfo = this.GetLegacyAssetInfo(info); + + // new content API + foreach (AssetOperationGroup group in this.RequestAssetOperations(info)) + yield return group; + + // legacy load operations +#pragma warning disable CS0612, CS0618 // deprecated code + foreach (ModLinked<IAssetLoader> loader in this.Loaders) + { + // check if loader applies + try + { + if (!loader.Data.CanLoad<T>(legacyInfo)) + continue; + } + catch (Exception ex) + { + loader.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // add operation + yield return new AssetOperationGroup( + mod: loader.Mod, + loadOperations: new[] + { + new AssetLoadOperation( + mod: loader.Mod, + priority: AssetLoadPriority.Exclusive, + onBehalfOf: null, + getData: assetInfo => loader.Data.Load<T>( + this.GetLegacyAssetInfo(assetInfo) + ) + ) + }, + editOperations: Array.Empty<AssetEditOperation>() + ); + } + + // legacy edit operations + foreach (var editor in this.Editors) + { + // check if editor applies + try + { + if (!editor.Data.CanEdit<T>(legacyInfo)) + continue; + } + catch (Exception ex) + { + editor.Mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{legacyInfo.Name}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + continue; + } + + // HACK + // + // If two editors have the same priority, they're applied in registration order (so + // whichever was registered first is applied first). Mods often depend on this + // behavior, like Json Assets registering its interceptors before Content Patcher. + // + // Unfortunately the old & new content APIs have separate lists, so new-API + // interceptors always ran before old-API interceptors with the same priority, + // regardless of the registration order *between* APIs. Since the new API works in + // a fundamentally different way (i.e. loads/edits are defined on asset request + // instead of by registering a global 'editor' or 'loader' class), there's no way + // to track registration order between them. + // + // Until we drop the old content API in SMAPI 4.0.0, this sets the priority for + // specific legacy editors to maintain compatibility. + AssetEditPriority priority = editor.Data.GetType().FullName switch + { + "JsonAssets.Framework.ContentInjector1" => AssetEditPriority.Default - 1, // must be applied before Content Patcher + _ => AssetEditPriority.Default + }; + + // add operation + yield return new AssetOperationGroup( + mod: editor.Mod, + loadOperations: Array.Empty<AssetLoadOperation>(), + editOperations: new[] + { + new AssetEditOperation( + mod: editor.Mod, + priority: priority, + onBehalfOf: null, + applyEdit: assetData => editor.Data.Edit<T>( + this.GetLegacyAssetData(assetData) + ) + ) + } + ); + } +#pragma warning restore CS0612, CS0618 + } + + /// <summary>Get an asset info compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset info.</param> + private IAssetInfo GetLegacyAssetInfo(IAssetInfo asset) + { + return new AssetInfo( + locale: this.GetLegacyLocale(asset), + assetName: this.GetLegacyAssetName(asset.Name), + type: asset.DataType, + getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName + ); + } + + /// <summary>Get an asset data compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset data.</param> + private IAssetData GetLegacyAssetData(IAssetData asset) + { + return new AssetDataForObject( + locale: this.GetLegacyLocale(asset), + assetName: this.GetLegacyAssetName(asset.Name), + data: asset.Data, + getNormalizedPath: this.MainContentManager.AssertAndNormalizeAssetName, + reflection: this.Reflection, + onDataReplaced: asset.ReplaceWith + ); + } + + /// <summary>Get the <see cref="IAssetInfo.Locale"/> value compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which expect the locale to default to the current game locale or an empty string.</summary> + /// <param name="asset">The non-legacy asset info to map.</param> + private string GetLegacyLocale(IAssetInfo asset) + { + return asset.Locale ?? this.GetLocale(); + } + + /// <summary>Get an asset name compatible with legacy <see cref="IAssetLoader"/> and <see cref="IAssetEditor"/> instances, which always expect the base name.</summary> + /// <param name="asset">The asset name to map.</param> + /// <returns>Returns the legacy asset name if needed, or the <paramref name="asset"/> if no change is needed.</returns> + private IAssetName GetLegacyAssetName(IAssetName asset) + { + // strip _international suffix + const string internationalSuffix = "_international"; + if (asset.Name.EndsWith(internationalSuffix)) + { + return new AssetName( + baseName: asset.Name[..^internationalSuffix.Length], + localeCode: null, + languageCode: null + ); + } + + // else strip locale + if (asset.LocaleCode != null) + return new AssetName(asset.BaseName, null, null); + + // else no change needed + return asset; + } } } diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 5645c0fa..b2e3ec0f 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -5,6 +5,7 @@ using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; @@ -29,9 +30,15 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates monitoring and logging.</summary> protected readonly IMonitor Monitor; + /// <summary>Simplifies access to private code.</summary> + protected readonly Reflector Reflection; + /// <summary>Whether to enable more aggressive memory optimizations.</summary> protected readonly bool AggressiveMemoryOptimizations; + /// <summary>Whether to automatically try resolving keys to a localized form if available.</summary> + protected bool TryLocalizeKeys = true; + /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; @@ -39,7 +46,7 @@ namespace StardewModdingAPI.Framework.ContentManagers private readonly Action<BaseContentManager> OnDisposing; /// <summary>A list of disposable assets.</summary> - private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>(); + private readonly List<WeakReference<IDisposable>> Disposables = new(); /// <summary>The disposable assets tracked by the base content manager.</summary> /// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks> @@ -56,7 +63,7 @@ namespace StardewModdingAPI.Framework.ContentManagers public LanguageCode Language => this.GetCurrentLanguage(); /// <inheritdoc /> - public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); + public string FullRootDirectory => Path.Combine(Constants.GamePath, this.RootDirectory); /// <inheritdoc /> public bool IsNamespaced { get; } @@ -82,51 +89,97 @@ namespace StardewModdingAPI.Framework.ContentManagers // init this.Name = name; this.Coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); - this.Cache = new ContentCache(this, reflection); + // ReSharper disable once VirtualMemberCallInConstructor -- LoadedAssets isn't overridden by SMAPI or Stardew Valley + this.Cache = new ContentCache(this.LoadedAssets); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); + this.Reflection = reflection; this.OnDisposing = onDisposing; this.IsNamespaced = isNamespaced; this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; // get asset data - this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue(); + this.BaseDisposableReferences = reflection.GetField<List<IDisposable>?>(this, "disposableAssets").GetValue() + ?? throw new InvalidOperationException("Can't initialize content manager: the required 'disposableAssets' field wasn't found."); } /// <inheritdoc /> - public override T Load<T>(string assetName) + public virtual bool DoesAssetExist<T>(IAssetName assetName) + where T : notnull { - return this.Load<T>(assetName, this.Language, useCache: true); + return this.Cache.ContainsKey(assetName.Name); } /// <inheritdoc /> - public override T Load<T>(string assetName, LanguageCode language) + [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")] + public sealed override T LoadBase<T>(string assetName) { - return this.Load<T>(assetName, language, useCache: true); + return this.Load<T>(assetName, LanguageCode.en); } /// <inheritdoc /> - public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - - /// <inheritdoc /> - [Obsolete("This method is implemented for the base game and should not be used directly. To load an asset from the underlying content manager directly, use " + nameof(BaseContentManager.RawLoad) + " instead.")] - public override T LoadBase<T>(string assetName) + public sealed override T Load<T>(string assetName) { - return this.Load<T>(assetName, LanguageCode.en, useCache: true); + return this.Load<T>(assetName, this.Language); } /// <inheritdoc /> - public virtual void OnLocaleChanged() { } + public sealed override T Load<T>(string assetName, LanguageCode language) + { + assetName = this.PrenormalizeRawAssetName(assetName); + IAssetName parsedName = this.Coordinator.ParseAssetName(assetName, allowLocales: this.TryLocalizeKeys); + return this.LoadLocalized<T>(parsedName, language, useCache: true); + } /// <inheritdoc /> - [Pure] - public string NormalizePathSeparators(string path) + public T LoadLocalized<T>(IAssetName assetName, LanguageCode language, bool useCache) + where T : notnull { - return this.Cache.NormalizePathSeparators(path); + // ignore locale in English (or if disabled) + if (!this.TryLocalizeKeys || language == LocalizedContentManager.LanguageCode.en) + return this.LoadExact<T>(assetName, useCache: useCache); + + // check for localized asset + if (!LocalizedContentManager.localizedAssetNames.TryGetValue(assetName.Name, out _)) + { + string localeCode = this.LanguageCodeString(language); + IAssetName localizedName = new AssetName(baseName: assetName.BaseName, localeCode: localeCode, languageCode: language); + + try + { + T data = this.LoadExact<T>(localizedName, useCache: useCache); + LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name; + return data; + } + catch (ContentLoadException) + { + localizedName = new AssetName(assetName.BaseName + "_international", null, null); + try + { + T data = this.LoadExact<T>(localizedName, useCache: useCache); + LocalizedContentManager.localizedAssetNames[assetName.Name] = localizedName.Name; + return data; + } + catch (ContentLoadException) + { + LocalizedContentManager.localizedAssetNames[assetName.Name] = assetName.Name; + } + } + } + + // use cached key + string rawName = LocalizedContentManager.localizedAssetNames[assetName.Name]; + if (assetName.Name != rawName) + assetName = this.Coordinator.ParseAssetName(rawName, allowLocales: this.TryLocalizeKeys); + return this.LoadExact<T>(assetName, useCache: useCache); } /// <inheritdoc /> + public abstract T LoadExact<T>(IAssetName assetName, bool useCache) + where T : notnull; + + /// <inheritdoc /> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - public string AssertAndNormalizeAssetName(string assetName) + public string AssertAndNormalizeAssetName(string? assetName) { // NOTE: the game checks for ContentLoadException to handle invalid keys, so avoid // throwing other types like ArgumentException here. @@ -154,19 +207,11 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <inheritdoc /> - public bool IsLoaded(string assetName, LanguageCode language) + public bool IsLoaded(IAssetName assetName) { - assetName = this.Cache.NormalizeKey(assetName); - return this.IsNormalizedKeyLoaded(assetName, language); + return this.Cache.ContainsKey(assetName.Name); } - /// <inheritdoc /> - public IEnumerable<string> GetAssetKeys() - { - return this.Cache.Keys - .Select(this.GetAssetName) - .Distinct(); - } /**** ** Cache invalidation @@ -177,13 +222,13 @@ namespace StardewModdingAPI.Framework.ContentManagers IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase); this.Cache.Remove((key, asset) => { - this.ParseCacheKey(key, out string assetName, out _); + string baseAssetName = this.Coordinator.ParseAssetName(key, allowLocales: this.TryLocalizeKeys).BaseName; // check if asset should be removed - bool remove = removeAssets.ContainsKey(assetName); - if (!remove && predicate(assetName, asset.GetType())) + bool remove = removeAssets.ContainsKey(baseAssetName); + if (!remove && predicate(baseAssetName, asset.GetType())) { - removeAssets[assetName] = asset; + removeAssets[baseAssetName] = asset; remove = true; } @@ -211,7 +256,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // dispose uncached assets foreach (WeakReference<IDisposable> reference in this.Disposables) { - if (reference.TryGetTarget(out IDisposable disposable)) + if (reference.TryGetTarget(out IDisposable? disposable)) { try { @@ -241,78 +286,64 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ + /// <summary>Apply initial normalization to a raw asset name before it's parsed.</summary> + /// <param name="assetName">The asset name to normalize.</param> + [return: NotNullIfNotNull("assetName")] + private string? PrenormalizeRawAssetName(string? assetName) + { + // trim + assetName = assetName?.Trim(); + + // For legacy reasons, mods can pass .xnb file extensions to the content pipeline which + // are then stripped. This will be re-added as needed when reading from raw files. + if (assetName?.EndsWith(".xnb") == true) + assetName = assetName[..^".xnb".Length]; + + return assetName; + } + + /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> + /// <param name="path">The file path to normalize.</param> + [Pure] + [return: NotNullIfNotNull("path")] + protected string? NormalizePathSeparators(string? path) + { + return this.Cache.NormalizePathSeparators(path); + } + /// <summary>Load an asset file directly from the underlying content manager.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The normalized asset key.</param> /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> - protected virtual T RawLoad<T>(string assetName, bool useCache) + protected virtual T RawLoad<T>(IAssetName assetName, bool useCache) { return useCache - ? base.LoadBase<T>(assetName) - : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); + ? base.LoadBase<T>(assetName.Name) + : this.ReadAsset<T>(assetName.Name, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); } /// <summary>Add tracking data to an asset and add it to the cache.</summary> /// <typeparam name="T">The type of asset to inject.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="value">The asset value.</param> - /// <param name="language">The language code for which to inject the asset.</param> /// <param name="useCache">Whether to save the asset to the asset cache.</param> - protected virtual void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache) + protected virtual void TrackAsset<T>(IAssetName assetName, T value, bool useCache) + where T : notnull { // track asset key if (value is Texture2D texture) - texture.Name = assetName; + texture.Name = assetName.Name; - // cache asset + // save to cache + // Note: even if the asset was loaded and cached right before this method was called, + // we need to fully re-inject it because a mod editor may have changed the asset in a + // way that doesn't change the instance stored in the cache, e.g. using + // `asset.ReplaceWith`. if (useCache) - { - assetName = this.AssertAndNormalizeAssetName(assetName); - this.Cache[assetName] = value; - } + this.Cache[assetName.Name] = value; // avoid hard disposable references; see remarks on the field this.BaseDisposableReferences.Clear(); } - - /// <summary>Parse a cache key into its component parts.</summary> - /// <param name="cacheKey">The input cache key.</param> - /// <param name="assetName">The original asset name.</param> - /// <param name="localeCode">The asset locale code (or <c>null</c> if not localized).</param> - protected void ParseCacheKey(string cacheKey, out string assetName, out string localeCode) - { - // handle localized key - if (!string.IsNullOrWhiteSpace(cacheKey)) - { - int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.Ordinal); - if (lastSepIndex >= 0) - { - string suffix = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); - if (this.Coordinator.TryGetLanguageEnum(suffix, out _)) - { - assetName = cacheKey.Substring(0, lastSepIndex); - localeCode = cacheKey.Substring(lastSepIndex + 1, cacheKey.Length - lastSepIndex - 1); - return; - } - } - } - - // handle simple key - assetName = cacheKey; - localeCode = null; - } - - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalizedAssetName">The normalized asset name.</param> - /// <param name="language">The language to check.</param> - protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language); - - /// <summary>Get the asset name from a cache key.</summary> - /// <param name="cacheKey">The input cache key.</param> - private string GetAssetName(string cacheKey) - { - this.ParseCacheKey(cacheKey, out string assetName, out string _); - return assetName; - } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs index ab198076..083df454 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -1,13 +1,14 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; -using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; -using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Internal; @@ -24,16 +25,7 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Fields *********/ /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> - private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); - - /// <summary>Interceptors which provide the initial versions of matching assets.</summary> - private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders; - - /// <summary>Interceptors which edit matching assets after they're loaded.</summary> - private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors; - - /// <summary>Maps asset names to their localized form, like <c>LooseSprites\Billboard => LooseSprites\Billboard.fr-FR</c> (localized) or <c>Maps\AnimalShop => Maps\AnimalShop</c> (not localized).</summary> - private IDictionary<string, string> LocalizedAssetNames => LocalizedContentManager.localizedAssetNames; + private readonly ContextHash<string> AssetsBeingLoaded = new(); /// <summary>Whether the next load is the first for any game content manager.</summary> private static bool IsFirstLoad = true; @@ -41,6 +33,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> private readonly Action OnLoadingFirstAsset; + /// <summary>A callback to invoke when an asset is fully loaded.</summary> + private readonly Action<BaseContentManager, IAssetName> OnAssetLoaded; + /********* ** Public methods @@ -55,15 +50,45 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + /// <param name="onAssetLoaded">A callback to invoke when an asset is fully loaded.</param> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) + public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations) { this.OnLoadingFirstAsset = onLoadingFirstAsset; + this.OnAssetLoaded = onAssetLoaded; } /// <inheritdoc /> - public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache) + public override bool DoesAssetExist<T>(IAssetName assetName) + { + if (base.DoesAssetExist<T>(assetName)) + return true; + + // vanilla asset + if (File.Exists(Path.Combine(this.RootDirectory, $"{assetName.Name}.xnb"))) + return true; + + // managed asset + if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) + return this.Coordinator.DoesManagedAssetExist<T>(contentManagerID, relativePath); + + // custom asset from a loader + string locale = this.GetLocale(); + IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); + AssetLoadOperation[] loaders = this.GetLoaders<object>(info).ToArray(); + + if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error)) + { + this.Monitor.Log(error, LogLevel.Warn); + return false; + } + + return loaders.Any(); + } + + /// <inheritdoc /> + public override T LoadExact<T>(IAssetName assetName, bool useCache) { // raise first-load callback if (GameContentManager.IsFirstLoad) @@ -72,71 +97,45 @@ namespace StardewModdingAPI.Framework.ContentManagers this.OnLoadingFirstAsset(); } - // normalize asset name - assetName = this.AssertAndNormalizeAssetName(assetName); - if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) - return this.Load<T>(newAssetName, newLanguage, useCache); - // get from cache - if (useCache && this.IsLoaded(assetName, language)) - return this.RawLoad<T>(assetName, language, useCache: true); + if (useCache && this.IsLoaded(assetName)) + return this.RawLoad<T>(assetName, useCache: true); // get managed asset - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); - this.TrackAsset(assetName, managedAsset, language, useCache); + this.TrackAsset(assetName, managedAsset, useCache); return managedAsset; } // load asset T data; - if (this.AssetsBeingLoaded.Contains(assetName)) + if (this.AssetsBeingLoaded.Contains(assetName.Name)) { this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}"); - data = this.RawLoad<T>(assetName, language, useCache); + data = this.RawLoad<T>(assetName, useCache); } else { - data = this.AssetsBeingLoaded.Track(assetName, () => + data = this.AssetsBeingLoaded.Track(assetName.Name, () => { - string locale = this.GetLocale(language); - IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); + IAssetInfo info = new AssetInfo(assetName.LocaleCode, assetName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader<T>(info) - ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName); + ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, useCache), this.AssertAndNormalizeAssetName, this.Reflection); asset = this.ApplyEditors<T>(info, asset); return (T)asset.Data; }); } - // update cache & return data - this.TrackAsset(assetName, data, language, useCache); - return data; - } + // update cache + this.TrackAsset(assetName, data, useCache); - /// <inheritdoc /> - public override void OnLocaleChanged() - { - base.OnLocaleChanged(); - - // find assets for which a translatable version was loaded - HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - foreach (string key in this.LocalizedAssetNames.Where(p => p.Key != p.Value).Select(p => p.Key)) - removeAssetNames.Add(this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) ? assetName : key); - - // invalidate translatable assets - string[] invalidated = this - .InvalidateCache((key, type) => - removeAssetNames.Contains(key) - || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName)) - ) - .Select(p => p.Key) - .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) - .ToArray(); - if (invalidated.Any()) - this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change."); + // raise event & return data + this.OnAssetLoaded(this, assetName); + return data; } /// <inheritdoc /> @@ -149,231 +148,101 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <inheritdoc /> - protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language) - { - string cachedKey = null; - bool localized = - language != LocalizedContentManager.LanguageCode.en - && !this.Coordinator.IsManagedAssetKey(normalizedAssetName) - && this.LocalizedAssetNames.TryGetValue(normalizedAssetName, out cachedKey); - - return localized - ? this.Cache.ContainsKey(cachedKey) - : this.Cache.ContainsKey(normalizedAssetName); - } - - /// <inheritdoc /> - protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache) - { - // handle explicit language in asset name - { - if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) - { - this.TrackAsset(newAssetName, value, newLanguage, useCache); - return; - } - } - - // save to cache - // Note: even if the asset was loaded and cached right before this method was called, - // we need to fully re-inject it here for two reasons: - // 1. So we can look up an asset by its base or localized key (the game/XNA logic - // only caches by the most specific key). - // 2. Because a mod asset loader/editor may have changed the asset in a way that - // doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`. - if (useCache) - { - string translatedKey = $"{assetName}.{this.GetLocale(language)}"; - base.TrackAsset(assetName, value, language, useCache: true); - if (this.Cache.ContainsKey(translatedKey)) - base.TrackAsset(translatedKey, value, language, useCache: true); - - // track whether the injected asset is translatable for is-loaded lookups - if (this.Cache.ContainsKey(translatedKey)) - this.LocalizedAssetNames[assetName] = translatedKey; - else if (this.Cache.ContainsKey(assetName)) - this.LocalizedAssetNames[assetName] = assetName; - else - this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error); - } - } - - /// <summary>Load an asset file directly from the underlying content manager.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="assetName">The normalized asset key.</param> - /// <param name="language">The language code for which to load content.</param> - /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> - /// <remarks>Derived from <see cref="LocalizedContentManager.Load{T}(string, LocalizedContentManager.LanguageCode)"/>.</remarks> - private T RawLoad<T>(string assetName, LanguageCode language, bool useCache) + /// <summary>Load the initial asset from the registered loaders.</summary> + /// <param name="info">The basic asset metadata.</param> + /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> + private IAssetData? ApplyLoader<T>(IAssetInfo info) + where T : notnull { - try + // find matching loader + AssetLoadOperation? loader; { - // use cached key - if (language == this.Language && this.LocalizedAssetNames.TryGetValue(assetName, out string cachedKey)) - return base.RawLoad<T>(cachedKey, useCache); + AssetLoadOperation[] loaders = this.GetLoaders<T>(info).OrderByDescending(p => p.Priority).ToArray(); - // try translated key - if (language != LocalizedContentManager.LanguageCode.en) + if (!this.AssertMaxOneRequiredLoader(info, loaders, out string? error)) { - string translatedKey = $"{assetName}.{this.GetLocale(language)}"; - try - { - T obj = base.RawLoad<T>(translatedKey, useCache); - this.LocalizedAssetNames[assetName] = translatedKey; - return obj; - } - catch (ContentLoadException) - { - this.LocalizedAssetNames[assetName] = assetName; - } + this.Monitor.Log(error, LogLevel.Warn); + return null; } - // try base asset - return base.RawLoad<T>(assetName, useCache); + loader = loaders.FirstOrDefault(); } - catch (ContentLoadException ex) when (ex.InnerException is FileNotFoundException innerEx && innerEx.InnerException == null) - { - throw new SContentLoadException($"Error loading \"{assetName}\": it isn't in the Content folder and no mod provided it."); - } - } - /// <summary>Parse an asset key that contains an explicit language into its asset name and language, if applicable.</summary> - /// <param name="rawAsset">The asset key to parse.</param> - /// <param name="assetName">The asset name without the language code.</param> - /// <param name="language">The language code removed from the asset name.</param> - /// <returns>Returns whether the asset key contains an explicit language and was successfully parsed.</returns> - private bool TryParseExplicitLanguageAssetKey(string rawAsset, out string assetName, out LanguageCode language) - { - if (string.IsNullOrWhiteSpace(rawAsset)) - throw new SContentLoadException("The asset key is empty."); - - // extract language code - int splitIndex = rawAsset.LastIndexOf('.'); - if (splitIndex != -1 && this.Coordinator.TryGetLanguageEnum(rawAsset.Substring(splitIndex + 1), out language)) - { - assetName = rawAsset.Substring(0, splitIndex); - return true; - } - - // no explicit language code found - assetName = rawAsset; - language = this.Language; - return false; - } - - /// <summary>Load the initial asset from the registered <see cref="Loaders"/>.</summary> - /// <param name="info">The basic asset metadata.</param> - /// <returns>Returns the loaded asset metadata, or <c>null</c> if no loader matched.</returns> - private IAssetData ApplyLoader<T>(IAssetInfo info) - { - // find matching loaders - var loaders = this.Loaders - .Where(entry => - { - try - { - return entry.Data.CanLoad<T>(info); - } - catch (Exception ex) - { - entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - return false; - } - }) - .ToArray(); - - // validate loaders - if (!loaders.Any()) + // no loader found + if (loader == null) return null; - if (loaders.Length > 1) - { - string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray(); - this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn); - return null; - } // fetch asset from loader - IModMetadata mod = loaders[0].Mod; - IAssetLoader loader = loaders[0].Data; + IModMetadata mod = loader.Mod; T data; try { - data = loader.Load<T>(info); - this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); + data = (T)loader.GetData(info); + this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.Name}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}."); } catch (Exception ex) { - mod.LogAsMod($"Mod crashed when loading asset '{info.AssetName}'. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when loading asset '{info.Name}'{this.GetOnBehalfOfLabel(loader.OnBehalfOf)}. SMAPI will use the default asset instead. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); return null; } // return matched asset - return this.TryFixAndValidateLoadedAsset(info, data, mod) - ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName) + return this.TryFixAndValidateLoadedAsset(info, data, loader) + ? new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection) : null; } - /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> + /// <summary>Apply any editors to a loaded asset.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The basic asset metadata.</param> /// <param name="asset">The loaded asset.</param> private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) + where T : notnull { - IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName, this.Reflection); // special case: if the asset was loaded with a more general type like 'object', call editors with the actual type instead. { Type actualType = asset.Data.GetType(); - Type actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null; + Type? actualOpenType = actualType.IsGenericType ? actualType.GetGenericTypeDefinition() : null; if (typeof(T) != actualType && (actualOpenType == typeof(Dictionary<,>) || actualOpenType == typeof(List<>) || actualType == typeof(Texture2D) || actualType == typeof(Map))) { return (IAssetData)this.GetType() - .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance) + .GetMethod(nameof(this.ApplyEditors), BindingFlags.NonPublic | BindingFlags.Instance)! .MakeGenericMethod(actualType) - .Invoke(this, new object[] { info, asset }); + .Invoke(this, new object[] { info, asset })!; } } // edit asset - foreach (var entry in this.Editors) + AssetEditOperation[] editors = this.GetEditors<T>(info).OrderBy(p => p.Priority).ToArray(); + foreach (AssetEditOperation editor in editors) { - // check for match - IModMetadata mod = entry.Mod; - IAssetEditor editor = entry.Data; - try - { - if (!editor.CanEdit<T>(info)) - continue; - } - catch (Exception ex) - { - mod.LogAsMod($"Mod crashed when checking whether it could edit asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); - continue; - } + IModMetadata mod = editor.Mod; // try edit object prevAsset = asset.Data; try { - editor.Edit<T>(asset); - this.Monitor.Log($"{mod.DisplayName} edited {info.AssetName}."); + editor.ApplyEdit(asset); + this.Monitor.Log($"{mod.DisplayName} edited {info.Name}{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}."); } catch (Exception ex) { - mod.LogAsMod($"Mod crashed when editing asset '{info.AssetName}', which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); + mod.LogAsMod($"Mod crashed when editing asset '{info.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)}, which may cause errors in-game. Error details:\n{ex.GetLogSummary()}", LogLevel.Error); } // validate edit + // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- it's only guaranteed non-null after this method if (asset.Data == null) { - mod.LogAsMod($"Mod incorrectly set asset '{info.AssetName}' to a null value; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{info.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to a null value; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } - else if (!(asset.Data is T)) + else if (asset.Data is not T) { - mod.LogAsMod($"Mod incorrectly set asset '{asset.AssetName}' to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); + mod.LogAsMod($"Mod incorrectly set asset '{asset.Name}'{this.GetOnBehalfOfLabel(editor.OnBehalfOf)} to incompatible type '{asset.Data.GetType()}', expected '{typeof(T)}'; ignoring override.", LogLevel.Warn); asset = GetNewData(prevAsset); } } @@ -382,32 +251,99 @@ namespace StardewModdingAPI.Framework.ContentManagers return asset; } + /// <summary>Get the asset loaders which handle an asset.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + private IEnumerable<AssetLoadOperation> GetLoaders<T>(IAssetInfo info) + where T : notnull + { + return this.Coordinator + .GetAssetOperations<T>(info) + .SelectMany(p => p.LoadOperations); + } + + /// <summary>Get the asset editors to apply to an asset.</summary> + /// <typeparam name="T">The asset type.</typeparam> + /// <param name="info">The basic asset metadata.</param> + private IEnumerable<AssetEditOperation> GetEditors<T>(IAssetInfo info) + where T : notnull + { + return this.Coordinator + .GetAssetOperations<T>(info) + .SelectMany(p => p.EditOperations); + } + + /// <summary>Assert that at most one loader will be applied to an asset.</summary> + /// <param name="info">The basic asset metadata.</param> + /// <param name="loaders">The asset loaders to apply.</param> + /// <param name="error">The error message to show to the user, if the method returns false.</param> + /// <returns>Returns true if only one loader will apply, else false.</returns> + private bool AssertMaxOneRequiredLoader(IAssetInfo info, AssetLoadOperation[] loaders, [NotNullWhen(false)] out string? error) + { + AssetLoadOperation[] required = loaders.Where(p => p.Priority == AssetLoadPriority.Exclusive).ToArray(); + if (required.Length <= 1) + { + error = null; + return true; + } + + string[] loaderNames = required + .Select(p => p.Mod.DisplayName + this.GetOnBehalfOfLabel(p.OnBehalfOf)) + .OrderBy(p => p) + .Distinct() + .ToArray(); + string errorPhrase = loaderNames.Length > 1 + ? $"Multiple mods want to provide the '{info.Name}' asset: {string.Join(", ", loaderNames)}" + : $"The '{loaderNames[0]}' mod wants to provide the '{info.Name}' asset multiple times"; + + error = $"{errorPhrase}. An asset can't be loaded multiple times, so SMAPI will use the default asset instead. Uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)"; + return false; + } + + /// <summary>Get a parenthetical label for log messages for the content pack on whose behalf the action is being performed, if any.</summary> + /// <param name="onBehalfOf">The content pack on whose behalf the action is being performed.</param> + /// <param name="parenthetical">whether to format the label as a parenthetical shown after the mod name like <c> (for the 'X' content pack)</c>, instead of a standalone label like <c>the 'X' content pack</c>.</param> + /// <returns>Returns the on-behalf-of label if applicable, else <c>null</c>.</returns> + [return: NotNullIfNotNull("onBehalfOf")] + private string? GetOnBehalfOfLabel(IModMetadata? onBehalfOf, bool parenthetical = true) + { + if (onBehalfOf == null) + return null; + + return parenthetical + ? $" (for the '{onBehalfOf.Manifest.Name}' content pack)" + : $"the '{onBehalfOf.Manifest.Name}' content pack"; + } + /// <summary>Validate that an asset loaded by a mod is valid and won't cause issues, and fix issues if possible.</summary> /// <typeparam name="T">The asset type.</typeparam> /// <param name="info">The basic asset metadata.</param> /// <param name="data">The loaded asset data.</param> - /// <param name="mod">The mod which loaded the asset.</param> + /// <param name="loader">The loader which loaded the asset.</param> /// <returns>Returns whether the asset passed validation checks (after any fixes were applied).</returns> - private bool TryFixAndValidateLoadedAsset<T>(IAssetInfo info, T data, IModMetadata mod) + private bool TryFixAndValidateLoadedAsset<T>(IAssetInfo info, [NotNullWhen(true)] T? data, AssetLoadOperation loader) + where T : notnull { + IModMetadata mod = loader.Mod; + // can't load a null asset if (data == null) { - mod.LogAsMod($"SMAPI blocked asset replacement for '{info.AssetName}': mod incorrectly set asset to a null value.", LogLevel.Error); + mod.LogAsMod($"SMAPI blocked asset replacement for '{info.Name}': {this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} incorrectly set asset to a null value.", LogLevel.Error); return false; } // when replacing a map, the vanilla tilesheets must have the same order and IDs if (data is Map loadedMap) { - TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.AssetName); + TilesheetReference[] vanillaTilesheetRefs = this.Coordinator.GetVanillaTilesheetIds(info.Name.Name); foreach (TilesheetReference vanillaSheet in vanillaTilesheetRefs) { // add missing tilesheet if (loadedMap.GetTileSheet(vanillaSheet.Id) == null) { - mod.Monitor.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn); - this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.AssetName}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource})."); + mod.Monitor!.LogOnce("SMAPI fixed maps loaded by this mod to prevent errors. See the log file for details.", LogLevel.Warn); + this.Monitor.Log($"Fixed broken map replacement: {mod.DisplayName} loaded '{info.Name}' without a required tilesheet (id: {vanillaSheet.Id}, source: {vanillaSheet.ImageSource})."); loadedMap.AddTileSheet(new TileSheet(vanillaSheet.Id, loadedMap, vanillaSheet.ImageSource, vanillaSheet.SheetSize, vanillaSheet.TileSize)); } @@ -417,17 +353,17 @@ namespace StardewModdingAPI.Framework.ContentManagers { // only show warning if not farm map // This is temporary: mods shouldn't do this for any vanilla map, but these are the ones we know will crash. Showing a warning for others instead gives modders time to update their mods, while still simplifying troubleshooting. - bool isFarmMap = info.AssetNameEquals("Maps/Farm") || info.AssetNameEquals("Maps/Farm_Combat") || info.AssetNameEquals("Maps/Farm_Fishing") || info.AssetNameEquals("Maps/Farm_Foraging") || info.AssetNameEquals("Maps/Farm_FourCorners") || info.AssetNameEquals("Maps/Farm_Island") || info.AssetNameEquals("Maps/Farm_Mining"); + bool isFarmMap = info.Name.IsEquivalentTo("Maps/Farm") || info.Name.IsEquivalentTo("Maps/Farm_Combat") || info.Name.IsEquivalentTo("Maps/Farm_Fishing") || info.Name.IsEquivalentTo("Maps/Farm_Foraging") || info.Name.IsEquivalentTo("Maps/Farm_FourCorners") || info.Name.IsEquivalentTo("Maps/Farm_Island") || info.Name.IsEquivalentTo("Maps/Farm_Mining"); - string reason = $"mod reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help."; + string reason = $"{this.GetOnBehalfOfLabel(loader.OnBehalfOf, parenthetical: false) ?? "mod"} reordered the original tilesheets, which {(isFarmMap ? "would cause a crash" : "often causes crashes")}.\nTechnical details for mod author: Expected order: {string.Join(", ", vanillaTilesheetRefs.Select(p => p.Id))}. See https://stardewvalleywiki.com/Modding:Maps#Tilesheet_order for help."; SCore.DeprecationManager.PlaceholderWarn("3.8.2", DeprecationLevel.PendingRemoval); if (isFarmMap) { - mod.LogAsMod($"SMAPI blocked '{info.AssetName}' map load: {reason}", LogLevel.Error); + mod.LogAsMod($"SMAPI blocked a '{info.Name}' map load: {reason}", LogLevel.Error); return false; } - mod.LogAsMod($"SMAPI found an issue with '{info.AssetName}' map load: {reason}", LogLevel.Warn); + mod.LogAsMod($"SMAPI found an issue with a '{info.Name}' map load: {reason}", LogLevel.Warn); } } } diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs index 61683ce6..1b0e1016 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManagerForAssetPropagation.cs @@ -21,13 +21,13 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Public methods *********/ /// <inheritdoc /> - public GameContentManagerForAssetPropagation(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, bool aggressiveMemoryOptimizations) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, onLoadingFirstAsset, aggressiveMemoryOptimizations) { } + public GameContentManagerForAssetPropagation(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset, Action<BaseContentManager, IAssetName> onAssetLoaded, bool aggressiveMemoryOptimizations) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, onLoadingFirstAsset, onAssetLoaded, aggressiveMemoryOptimizations) { } /// <inheritdoc /> - public override T Load<T>(string assetName, LanguageCode language, bool useCache) + public override T LoadExact<T>(IAssetName assetName, bool useCache) { - T data = base.Load<T>(assetName, language, useCache); + T data = base.LoadExact<T>(assetName, useCache); if (data is Texture2D texture) texture.Tag = this.Tag; @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Get whether a texture was loaded by this content manager.</summary> /// <param name="texture">The texture to check.</param> - public bool IsResponsibleFor(Texture2D texture) + public bool IsResponsibleFor(Texture2D? texture) { return texture?.Tag is string tag diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index d7963305..ac67cad5 100644 --- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Diagnostics.Contracts; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewValley; @@ -29,22 +28,31 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Methods *********/ - /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <summary>Get whether an asset exists and can be loaded.</summary> + /// <typeparam name="T">The expected asset type.</typeparam> + /// <param name="assetName">The normalized asset name.</param> + bool DoesAssetExist<T>(IAssetName assetName) + where T: notnull; + + /// <summary>Load an asset through the content pipeline, using a localized variant of the <paramref name="assetName"/> if available.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="language">The language code for which to load content.</param> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="language">The language for which to load the asset.</param> /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> - T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); + T LoadLocalized<T>(IAssetName assetName, LocalizedContentManager.LanguageCode language, bool useCache) + where T : notnull; - /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="AssertAndNormalizeAssetName"/> instead.</summary> - /// <param name="path">The file path to normalize.</param> - [Pure] - string NormalizePathSeparators(string path); + /// <summary>Load an asset through the content pipeline, using the exact asset name without checking for localized variants.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + T LoadExact<T>(IAssetName assetName, bool useCache) + where T : notnull; /// <summary>Assert that the given key has a valid format and return a normalized form consistent with the underlying cache.</summary> /// <param name="assetName">The asset key to check.</param> /// <exception cref="SContentLoadException">The asset key is empty or contains invalid characters.</exception> - string AssertAndNormalizeAssetName(string assetName); + string AssertAndNormalizeAssetName(string? assetName); /// <summary>Get the current content locale.</summary> string GetLocale(); @@ -55,19 +63,12 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Get whether the content manager has already loaded and cached the given asset.</summary> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="language">The language.</param> - bool IsLoaded(string assetName, LocalizedContentManager.LanguageCode language); - - /// <summary>Get the cached asset keys.</summary> - IEnumerable<string> GetAssetKeys(); + bool IsLoaded(IAssetName assetName); /// <summary>Purge matched assets from the cache.</summary> /// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <returns>Returns the invalidated asset names and instances.</returns> IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false); - - /// <summary>Perform any cleanup needed when the locale changes.</summary> - void OnLocaleChanged(); } } diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index beb90a5d..8f64c5a8 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -32,8 +32,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The game content manager used for map tilesheets not provided by the mod.</summary> private readonly IContentManager GameContentManager; - /// <summary>The language code for language-agnostic mod assets.</summary> - private readonly LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary> + private readonly CaseInsensitivePathLookup RelativePathCache; /// <summary>If a map tilesheet's image source has no file extensions, the file extensions to check for in the local mod folder.</summary> private readonly string[] LocalTilesheetExtensions = { ".png", ".xnb" }; @@ -55,33 +55,31 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <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> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, bool aggressiveMemoryOptimizations) + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="rootDirectory"/>.</param> + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string modName, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing, bool aggressiveMemoryOptimizations, CaseInsensitivePathLookup relativePathCache) : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true, aggressiveMemoryOptimizations: aggressiveMemoryOptimizations) { this.GameContentManager = gameContentManager; + this.RelativePathCache = relativePathCache; this.JsonHelper = jsonHelper; this.ModName = modName; - } - /// <inheritdoc /> - public override T Load<T>(string assetName) - { - return this.Load<T>(assetName, this.DefaultLanguage, useCache: false); + this.TryLocalizeKeys = false; } /// <inheritdoc /> - public override T Load<T>(string assetName, LanguageCode language) + public override bool DoesAssetExist<T>(IAssetName assetName) { - return this.Load<T>(assetName, language, useCache: false); + if (base.DoesAssetExist<T>(assetName)) + return true; + + FileInfo file = this.GetModFile(assetName.Name); + return file.Exists; } /// <inheritdoc /> - public override T Load<T>(string assetName, LanguageCode language, bool useCache) + public override T LoadExact<T>(IAssetName assetName, bool useCache) { - // normalize key - bool isXnbFile = Path.GetExtension(assetName).ToLower() == ".xnb"; - assetName = this.AssertAndNormalizeAssetName(assetName); - // disable caching // This is necessary to avoid assets being shared between content managers, which can // cause changes to an asset through one content manager affecting the same asset in @@ -90,106 +88,43 @@ namespace StardewModdingAPI.Framework.ContentManagers if (useCache) throw new InvalidOperationException("Mod content managers don't support asset caching."); - // disable language handling - // Mod files don't support automatic translation logic, so this should never happen. - if (language != this.DefaultLanguage) - throw new InvalidOperationException("Localized assets aren't supported by the mod content manager."); - // resolve managed asset key { - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + if (this.Coordinator.TryParseManagedAssetKey(assetName.Name, out string? contentManagerID, out IAssetName? relativePath)) { if (contentManagerID != this.Name) - throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod."); + throw this.GetLoadError(assetName, "can't load a different mod's managed asset key through this mod content manager."); assetName = relativePath; } } // get local asset - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); T asset; try { // get file - FileInfo file = this.GetModFile(isXnbFile ? $"{assetName}.xnb" : assetName); // .xnb extension is stripped from asset names passed to the content manager + FileInfo file = this.GetModFile(assetName.Name); if (!file.Exists) - throw GetContentError("the specified path doesn't exist."); + throw this.GetLoadError(assetName, "the specified path doesn't exist."); // load content - switch (file.Extension.ToLower()) + asset = file.Extension.ToLower() switch { - // XNB file - case ".xnb": - { - asset = this.RawLoad<T>(assetName, useCache: false); - if (asset is Map map) - { - map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: true); - } - } - break; - - // unpacked Bitmap font - case ".fnt": - { - string source = File.ReadAllText(file.FullName); - asset = (T)(object)new XmlSource(source); - } - break; - - // unpacked data - case ".json": - { - if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out asset)) - throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above - } - break; - - // unpacked image - case ".png": - { - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using FileStream stream = File.OpenRead(file.FullName); - - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - asset = (T)(object)texture; - } - break; - - // unpacked map - case ".tbin": - case ".tmx": - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - map.assetPath = assetName; - this.FixTilesheetPaths(map, relativeMapPath: assetName, fixEagerPathPrefixes: false); - asset = (T)(object)map; - } - break; - - default: - throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', or '.xnb'."); - } + ".fnt" => this.LoadFont<T>(assetName, file), + ".json" => this.LoadDataFile<T>(assetName, file), + ".png" => this.LoadImageFile<T>(assetName, file), + ".tbin" or ".tmx" => this.LoadMapFile<T>(assetName, file), + ".xnb" => this.LoadXnbFile<T>(assetName), + _ => this.HandleUnknownFileType<T>(assetName, file) + }; } - catch (Exception ex) when (!(ex is SContentLoadException)) + catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); + throw this.GetLoadError(assetName, "an unexpected occurred.", ex); } // track & return asset - this.TrackAsset(assetName, asset, language, useCache); + this.TrackAsset(assetName, asset, useCache: false); return asset; } @@ -202,36 +137,137 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Get the underlying key in the game's content cache for an asset. This does not validate whether the asset exists.</summary> /// <param name="key">The local path to a content file relative to the mod folder.</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> - public string GetInternalAssetKey(string key) + public IAssetName GetInternalAssetKey(string key) { FileInfo file = this.GetModFile(key); - string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); - return Path.Combine(this.Name, relativePath); + string relativePath = Path.GetRelativePath(this.RootDirectory, file.FullName); + string internalKey = Path.Combine(this.Name, relativePath); + + return this.Coordinator.ParseAssetName(internalKey, allowLocales: false); } /********* ** Private methods *********/ - /// <inheritdoc /> - protected override bool IsNormalizedKeyLoaded(string normalizedAssetName, LanguageCode language) + /// <summary>Load an unpacked font file (<c>.fnt</c>).</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file to load.</param> + private T LoadFont<T>(IAssetName assetName, FileInfo file) { - return this.Cache.ContainsKey(normalizedAssetName); + // validate + if (!typeof(T).IsAssignableFrom(typeof(XmlSource))) + throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(XmlSource)}'."); + + // load + string source = File.ReadAllText(file.FullName); + return (T)(object)new XmlSource(source); + } + + /// <summary>Load an unpacked data file (<c>.json</c>).</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file to load.</param> + private T LoadDataFile<T>(IAssetName assetName, FileInfo file) + { + if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T? asset)) + throw this.GetLoadError(assetName, "the JSON file is invalid."); // should never happen since we check for file existence before calling this method + + return asset; + } + + /// <summary>Load an unpacked image file (<c>.json</c>).</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file to load.</param> + private T LoadImageFile<T>(IAssetName assetName, FileInfo file) + { + // validate + if (typeof(T) != typeof(Texture2D)) + throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); + + // load + using FileStream stream = File.OpenRead(file.FullName); + Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); + texture = this.PremultiplyTransparency(texture); + return (T)(object)texture; + } + + /// <summary>Load an unpacked image file (<c>.tbin</c> or <c>.tmx</c>).</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file to load.</param> + private T LoadMapFile<T>(IAssetName assetName, FileInfo file) + { + // validate + if (typeof(T) != typeof(Map)) + throw this.GetLoadError(assetName, $"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // load + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + map.assetPath = assetName.Name; + this.FixTilesheetPaths(map, relativeMapPath: assetName.Name, fixEagerPathPrefixes: false); + return (T)(object)map; + } + + /// <summary>Load a packed file (<c>.xnb</c>).</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + private T LoadXnbFile<T>(IAssetName assetName) + { + // the underlying content manager adds a .xnb extension implicitly, so + // we need to strip it here to avoid trying to load a '.xnb.xnb' file. + IAssetName loadName = assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase) + ? this.Coordinator.ParseAssetName(assetName.Name[..^".xnb".Length], allowLocales: false) + : assetName; + + // load asset + T asset = this.RawLoad<T>(loadName, useCache: false); + if (asset is Map map) + { + map.assetPath = loadName.Name; + this.FixTilesheetPaths(map, relativeMapPath: loadName.Name, fixEagerPathPrefixes: true); + } + + return asset; + } + + /// <summary>Handle a request to load a file type that isn't supported by SMAPI.</summary> + /// <typeparam name="T">The expected file type.</typeparam> + /// <param name="assetName">The asset name relative to the loader root directory.</param> + /// <param name="file">The file to load.</param> + private T HandleUnknownFileType<T>(IAssetName assetName, FileInfo file) + { + throw this.GetLoadError(assetName, $"unknown file extension '{file.Extension}'; must be one of '.fnt', '.json', '.png', '.tbin', '.tmx', or '.xnb'."); + } + + /// <summary>Get an error which indicates that an asset couldn't be loaded.</summary> + /// <param name="assetName">The asset name that failed to load.</param> + /// <param name="reasonPhrase">The reason the file couldn't be loaded.</param> + /// <param name="exception">The underlying exception, if applicable.</param> + private SContentLoadException GetLoadError(IAssetName assetName, string reasonPhrase, Exception? exception = null) + { + return new($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}", exception); } /// <summary>Get a file from the mod folder.</summary> /// <param name="path">The asset path relative to the content folder.</param> private FileInfo GetModFile(string path) { + // map to case-insensitive path if needed + path = this.RelativePathCache.GetFilePath(path); + // try exact match - FileInfo file = new FileInfo(Path.Combine(this.FullRootDirectory, path)); + FileInfo file = new(Path.Combine(this.FullRootDirectory, path)); // try with default extension - if (!file.Exists && file.Extension == string.Empty) + if (!file.Exists) { foreach (string extension in this.LocalTilesheetExtensions) { - FileInfo result = new FileInfo(file.FullName + extension); + FileInfo result = new(file.FullName + extension); if (result.Exists) { file = result; @@ -252,16 +288,20 @@ namespace StardewModdingAPI.Framework.ContentManagers // premultiply pixels Color[] data = new Color[texture.Width * texture.Height]; texture.GetData(data); + bool changed = false; for (int i = 0; i < data.Length; i++) { - var pixel = data[i]; - if (pixel.A == byte.MinValue || pixel.A == byte.MaxValue) + Color pixel = data[i]; + if (pixel.A is (byte.MinValue or byte.MaxValue)) continue; // no need to change fully transparent/opaque pixels data[i] = new Color(pixel.R * pixel.A / byte.MaxValue, pixel.G * pixel.A / byte.MaxValue, pixel.B * pixel.A / byte.MaxValue, pixel.A); // slower version: Color.FromNonPremultiplied(data[i].ToVector4()) + changed = true; } - texture.SetData(data); + if (changed) + texture.SetData(data); + return texture; } @@ -296,15 +336,18 @@ namespace StardewModdingAPI.Framework.ContentManagers // load best match try { - if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out string assetName, out string error)) + if (!this.TryGetTilesheetAssetName(relativeMapFolder, imageSource, out IAssetName? assetName, out string? error)) throw new SContentLoadException($"{errorPrefix} {error}"); - if (assetName != tilesheet.ImageSource) - this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'."); + if (assetName is not null) + { + if (!assetName.IsEquivalentTo(tilesheet.ImageSource)) + this.Monitor.VerboseLog($" Mapped tilesheet '{tilesheet.ImageSource}' to '{assetName}'."); - tilesheet.ImageSource = assetName; + tilesheet.ImageSource = assetName.Name; + } } - catch (Exception ex) when (!(ex is SContentLoadException)) + catch (Exception ex) when (ex is not SContentLoadException) { throw new SContentLoadException($"{errorPrefix} The tilesheet couldn't be loaded.", ex); } @@ -318,7 +361,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="error">A message indicating why the file couldn't be loaded.</param> /// <returns>Returns whether the asset name was found.</returns> /// <remarks>See remarks on <see cref="FixTilesheetPaths"/>.</remarks> - private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out string assetName, out string error) + private bool TryGetTilesheetAssetName(string modRelativeMapFolder, string relativePath, out IAssetName? assetName, out string? error) { assetName = null; error = null; @@ -326,7 +369,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // nothing to do if (string.IsNullOrWhiteSpace(relativePath)) { - assetName = relativePath; + assetName = null; return true; } @@ -350,10 +393,10 @@ namespace StardewModdingAPI.Framework.ContentManagers } // get from game assets - string contentKey = this.GetContentKeyForTilesheetImageSource(relativePath); + IAssetName contentKey = this.Coordinator.ParseAssetName(this.GetContentKeyForTilesheetImageSource(relativePath), allowLocales: false); try { - this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + this.GameContentManager.LoadLocalized<Texture2D>(contentKey, this.GameContentManager.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset assetName = contentKey; return true; } @@ -366,7 +409,7 @@ namespace StardewModdingAPI.Framework.ContentManagers // if the content file doesn't exist, that doesn't mean the error here is a // content-not-found error. Unfortunately XNA doesn't provide a good way to // detect the error type. - if (this.GetContentFolderFileExists(contentKey)) + if (this.GetContentFolderFileExists(contentKey.Name)) throw; } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index b6add7b5..dde33c95 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.IO; using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Toolkit.Serialization; @@ -13,14 +12,11 @@ namespace StardewModdingAPI.Framework /********* ** Fields *********/ - /// <summary>Provides an API for loading content assets.</summary> - private readonly IContentHelper Content; - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; - /// <summary>A cache of case-insensitive => exact relative paths within the content pack, for case-insensitive file lookups on Linux/macOS.</summary> - private readonly IDictionary<string, string> RelativePaths = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + /// <summary>A case-insensitive lookup of relative paths within the <see cref="DirectoryPath"/>.</summary> + private readonly CaseInsensitivePathLookup RelativePathCache; /********* @@ -35,6 +31,9 @@ namespace StardewModdingAPI.Framework /// <inheritdoc /> public ITranslationHelper Translation => this.TranslationImpl; + /// <inheritdoc /> + public IModContentHelper ModContent { get; } + /// <summary>The underlying translation helper.</summary> internal TranslationHelper TranslationImpl { get; set; } @@ -45,22 +44,18 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="directoryPath">The full path to the content pack's folder.</param> /// <param name="manifest">The content pack's manifest.</param> - /// <param name="content">Provides an API for loading content assets.</param> + /// <param name="content">Provides an API for loading content assets from the content pack's folder.</param> /// <param name="translation">Provides translations stored in the content pack's <c>i18n</c> folder.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, TranslationHelper translation, JsonHelper jsonHelper) + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="directoryPath"/>.</param> + public ContentPack(string directoryPath, IManifest manifest, IModContentHelper content, TranslationHelper translation, JsonHelper jsonHelper, CaseInsensitivePathLookup relativePathCache) { this.DirectoryPath = directoryPath; this.Manifest = manifest; - this.Content = content; + this.ModContent = content; this.TranslationImpl = translation; this.JsonHelper = jsonHelper; - - foreach (string path in Directory.EnumerateFiles(this.DirectoryPath, "*", SearchOption.AllDirectories)) - { - string relativePath = path.Substring(this.DirectoryPath.Length + 1); - this.RelativePaths[relativePath] = relativePath; - } + this.RelativePathCache = relativePathCache; } /// <inheritdoc /> @@ -72,12 +67,12 @@ namespace StardewModdingAPI.Framework } /// <inheritdoc /> - public TModel ReadJsonFile<TModel>(string path) where TModel : class + public TModel? ReadJsonFile<TModel>(string path) where TModel : class { path = PathUtilities.NormalizePath(path); FileInfo file = this.GetFile(path); - return file.Exists && this.JsonHelper.ReadJsonFileIfExists(file.FullName, out TModel model) + return file.Exists && this.JsonHelper.ReadJsonFileIfExists(file.FullName, out TModel? model) ? model : null; } @@ -90,44 +85,28 @@ namespace StardewModdingAPI.Framework FileInfo file = this.GetFile(path, out path); this.JsonHelper.WriteJsonFile(file.FullName, data); - if (!this.RelativePaths.ContainsKey(path)) - this.RelativePaths[path] = path; + this.RelativePathCache.Add(path); } /// <inheritdoc /> + [Obsolete] public T LoadAsset<T>(string key) + where T : notnull { - key = PathUtilities.NormalizePath(key); - - key = this.GetCaseInsensitiveRelativePath(key); - return this.Content.Load<T>(key, ContentSource.ModFolder); + return this.ModContent.Load<T>(key); } /// <inheritdoc /> + [Obsolete] public string GetActualAssetKey(string key) { - key = PathUtilities.NormalizePath(key); - - key = this.GetCaseInsensitiveRelativePath(key); - return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); + return this.ModContent.GetInternalAssetName(key).Name; } /********* ** Private methods *********/ - /// <summary>Get the real relative path from a case-insensitive path.</summary> - /// <param name="relativePath">The normalized relative path.</param> - private string GetCaseInsensitiveRelativePath(string relativePath) - { - if (!PathUtilities.IsSafeRelativePath(relativePath)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path."); - - return !string.IsNullOrWhiteSpace(relativePath) && this.RelativePaths.TryGetValue(relativePath, out string caseInsensitivePath) - ? caseInsensitivePath - : relativePath; - } - /// <summary>Get the underlying file info.</summary> /// <param name="relativePath">The normalized file path relative to the content pack directory.</param> private FileInfo GetFile(string relativePath) @@ -140,7 +119,11 @@ namespace StardewModdingAPI.Framework /// <param name="actualRelativePath">The relative path after case-insensitive matching.</param> private FileInfo GetFile(string relativePath, out string actualRelativePath) { - actualRelativePath = this.GetCaseInsensitiveRelativePath(relativePath); + if (!PathUtilities.IsSafeRelativePath(relativePath)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)} methods with a relative path."); + + actualRelativePath = this.RelativePathCache.GetFilePath(relativePath); + return new FileInfo(Path.Combine(this.DirectoryPath, actualRelativePath)); } } diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 107481e7..24084830 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -39,7 +39,7 @@ namespace StardewModdingAPI.Framework } /// <inheritdoc /> - public bool Equals(ICursorPosition other) + public bool Equals(ICursorPosition? other) { return other != null && this.AbsolutePixels == other.AbsolutePixels; } diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs deleted file mode 100644 index fc1b434b..00000000 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace StardewModdingAPI.Framework -{ - /// <summary>Manages deprecation warnings.</summary> - internal class DeprecationManager - { - /********* - ** Fields - *********/ - /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary> - private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - - /// <summary>Encapsulates monitoring and logging for a given module.</summary> - private readonly IMonitor Monitor; - - /// <summary>Tracks the installed mods.</summary> - private readonly ModRegistry ModRegistry; - - /// <summary>The queued deprecation warnings to display.</summary> - private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param> - /// <param name="modRegistry">Tracks the installed mods.</param> - public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) - { - this.Monitor = monitor; - this.ModRegistry = modRegistry; - } - - /// <summary>Get the source name for a mod from its unique ID.</summary> - public string GetSourceNameFromStack() - { - return this.ModRegistry.GetFromStack()?.DisplayName; - } - - /// <summary>Get the source name for a mod from its unique ID.</summary> - /// <param name="modId">The mod's unique ID.</param> - public string GetSourceName(string modId) - { - return this.ModRegistry.Get(modId)?.DisplayName; - } - - /// <summary>Log a deprecation warning.</summary> - /// <param name="source">The friendly mod name which used the deprecated code.</param> - /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> - /// <param name="version">The SMAPI version which deprecated it.</param> - /// <param name="severity">How deprecated the code is.</param> - public void Warn(string source, string nounPhrase, string version, DeprecationLevel severity) - { - // ignore if already warned - if (!this.MarkWarned(source ?? this.GetSourceNameFromStack() ?? "<unknown>", nounPhrase, version)) - return; - - // queue warning - this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity, Environment.StackTrace)); - } - - /// <summary>A placeholder method used to track deprecated code for which a separate warning will be shown.</summary> - /// <param name="version">The SMAPI version which deprecated it.</param> - /// <param name="severity">How deprecated the code is.</param> - public void PlaceholderWarn(string version, DeprecationLevel severity) { } - - /// <summary>Print any queued messages.</summary> - public void PrintQueued() - { - foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) - { - // build message - string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; - - // get log level - LogLevel level; - switch (warning.Level) - { - case DeprecationLevel.Notice: - level = LogLevel.Trace; - break; - - case DeprecationLevel.Info: - level = LogLevel.Debug; - break; - - case DeprecationLevel.PendingRemoval: - level = LogLevel.Warn; - break; - - default: - throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'."); - } - - // log message - if (warning.ModName != null) - this.Monitor.Log(message, level); - else - { - if (level == LogLevel.Trace) - this.Monitor.Log($"{message}\n{warning.StackTrace}", level); - else - { - this.Monitor.Log(message, level); - this.Monitor.Log(warning.StackTrace, LogLevel.Debug); - } - } - } - - this.QueuedWarnings.Clear(); - } - - - /********* - ** Private methods - *********/ - /// <summary>Mark a deprecation warning as already logged.</summary> - /// <param name="source">The friendly name of the assembly which used the deprecated code.</param> - /// <param name="nounPhrase">A noun phrase describing what is deprecated (e.g. "the Extensions.AsInt32 method").</param> - /// <param name="version">The SMAPI version which deprecated it.</param> - /// <returns>Returns whether the deprecation was successfully marked as warned. Returns <c>false</c> if it was already marked.</returns> - private bool MarkWarned(string source, string nounPhrase, string version) - { - if (string.IsNullOrWhiteSpace(source)) - throw new InvalidOperationException("The deprecation source cannot be empty."); - - string key = $"{source}::{nounPhrase}::{version}"; - if (this.LoggedDeprecations.Contains(key)) - return false; - this.LoggedDeprecations.Add(key); - return true; - } - } -} diff --git a/src/SMAPI/Framework/DeprecationLevel.cs b/src/SMAPI/Framework/Deprecations/DeprecationLevel.cs index 12b50952..8b15b59a 100644 --- a/src/SMAPI/Framework/DeprecationLevel.cs +++ b/src/SMAPI/Framework/Deprecations/DeprecationLevel.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Deprecations { /// <summary>Indicates how deprecated something is.</summary> internal enum DeprecationLevel diff --git a/src/SMAPI/Framework/Deprecations/DeprecationManager.cs b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs new file mode 100644 index 00000000..288abde2 --- /dev/null +++ b/src/SMAPI/Framework/Deprecations/DeprecationManager.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text; + +namespace StardewModdingAPI.Framework.Deprecations +{ + /// <summary>Manages deprecation warnings.</summary> + internal class DeprecationManager + { + /********* + ** Fields + *********/ + /// <summary>The deprecations which have already been logged (as 'mod name::noun phrase::version').</summary> + private readonly HashSet<string> LoggedDeprecations = new(StringComparer.OrdinalIgnoreCase); + + /// <summary>Encapsulates monitoring and logging for a given module.</summary> + private readonly IMonitor Monitor; + + /// <summary>Tracks the installed mods.</summary> + private readonly ModRegistry ModRegistry; + + /// <summary>The queued deprecation warnings to display.</summary> + private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Encapsulates monitoring and logging for a given module.</param> + /// <param name="modRegistry">Tracks the installed mods.</param> + public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) + { + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Get a mod for the closest assembly registered as a source of deprecation warnings.</summary> + /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns> + public IModMetadata? GetModFromStack() + { + return this.ModRegistry.GetFromStack(); + } + + /// <summary>Get a mod from its unique ID.</summary> + /// <param name="modId">The mod's unique ID.</param> + public IModMetadata? GetMod(string modId) + { + return this.ModRegistry.Get(modId); + } + + /// <summary>Log a deprecation warning.</summary> + /// <param name="source">The mod which used the deprecated code, if known.</param> + /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="severity">How deprecated the code is.</param> + /// <param name="unlessStackIncludes">A list of stack trace substrings which should suppress deprecation warnings if they appear in the stack trace.</param> + /// <param name="logStackTrace">Whether to log a stack trace showing where the deprecated code is in the mod.</param> + public void Warn(IModMetadata? source, string nounPhrase, string version, DeprecationLevel severity, string[]? unlessStackIncludes = null, bool logStackTrace = true) + { + // skip if already warned + string cacheKey = $"{source?.DisplayName ?? "<unknown>"}::{nounPhrase}::{version}"; + if (this.LoggedDeprecations.Contains(cacheKey)) + return; + + // warn if valid + ImmutableStackTrace stack = ImmutableStackTrace.Get(skipFrames: 1); + if (!this.ShouldSuppress(stack, unlessStackIncludes)) + { + this.LoggedDeprecations.Add(cacheKey); + this.QueuedWarnings.Add(new DeprecationWarning(source, nounPhrase, version, severity, stack, logStackTrace)); + } + } + + /// <summary>A placeholder method used to track deprecated code for which a separate warning will be shown.</summary> + /// <param name="version">The SMAPI version which deprecated it.</param> + /// <param name="severity">How deprecated the code is.</param> + public void PlaceholderWarn(string version, DeprecationLevel severity) { } + + /// <summary>Print any queued messages.</summary> + public void PrintQueued() + { + foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) + { + // build message + string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; + + // get log level + LogLevel level; + switch (warning.Level) + { + case DeprecationLevel.Notice: + level = LogLevel.Trace; + break; + + case DeprecationLevel.Info: + level = LogLevel.Debug; + break; + + case DeprecationLevel.PendingRemoval: + level = LogLevel.Warn; + break; + + default: + throw new NotSupportedException($"Unknown deprecation level '{warning.Level}'."); + } + + // log message + if (level == LogLevel.Trace) + { + if (warning.LogStackTrace) + message += $"\n{this.GetSimplifiedStackTrace(warning.StackTrace, warning.Mod)}"; + this.Monitor.Log(message, level); + } + else + { + this.Monitor.Log(message, level); + if (warning.LogStackTrace) + this.Monitor.Log(this.GetSimplifiedStackTrace(warning.StackTrace, warning.Mod), LogLevel.Debug); + } + } + + this.QueuedWarnings.Clear(); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether a deprecation warning should be suppressed.</summary> + /// <param name="stack">The stack trace for which it was raised.</param> + /// <param name="unlessStackIncludes">A list of stack trace substrings which should suppress deprecation warnings if they appear in the stack trace.</param> + private bool ShouldSuppress(ImmutableStackTrace stack, string[]? unlessStackIncludes) + { + if (unlessStackIncludes?.Any() == true) + { + string stackTrace = stack.ToString(); + foreach (string method in unlessStackIncludes) + { + if (stackTrace.Contains(method)) + return true; + } + } + + return false; + } + + /// <summary>Get the simplest stack trace which shows where in the mod the deprecated code was called from.</summary> + /// <param name="stack">The stack trace.</param> + /// <param name="mod">The mod for which to show a stack trace.</param> + private string GetSimplifiedStackTrace(ImmutableStackTrace stack, IModMetadata? mod) + { + // unknown mod, show entire stack trace + if (mod == null) + return stack.ToString(); + + // get frame info + var frames = stack + .GetFrames() + .Select(frame => (Frame: frame, Mod: this.ModRegistry.GetFrom(frame))) + .ToArray(); + var modIds = new HashSet<string>( + from frame in frames + let id = frame.Mod?.Manifest.UniqueID + where id != null + select id + ); + + // can't filter to the target mod + if (modIds.Count != 1 || !modIds.Contains(mod.Manifest.UniqueID)) + return stack.ToString(); + + // get stack frames for the target mod, plus one for context + var framesStartingAtMod = frames.SkipWhile(p => p.Mod == null).ToArray(); + var displayFrames = framesStartingAtMod.TakeWhile(p => p.Mod != null).ToArray(); + displayFrames = displayFrames.Concat(framesStartingAtMod.Skip(displayFrames.Length).Take(1)).ToArray(); + + // build stack trace + StringBuilder str = new(); + foreach (var frame in displayFrames) + str.Append(new StackTrace(frame.Frame)); + return str.ToString().TrimEnd(); + } + } +} diff --git a/src/SMAPI/Framework/DeprecationWarning.cs b/src/SMAPI/Framework/Deprecations/DeprecationWarning.cs index 5201b06c..5936517b 100644 --- a/src/SMAPI/Framework/DeprecationWarning.cs +++ b/src/SMAPI/Framework/Deprecations/DeprecationWarning.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework +namespace StardewModdingAPI.Framework.Deprecations { /// <summary>A deprecation warning for a mod.</summary> internal class DeprecationWarning @@ -6,8 +6,11 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The affected mod's display name.</summary> - public string ModName { get; } + /// <summary>The affected mod.</summary> + public IModMetadata? Mod { get; } + + /// <summary>Get the display name for the affected mod.</summary> + public string ModName => this.Mod?.DisplayName ?? "<unknown mod>"; /// <summary>A noun phrase describing what is deprecated.</summary> public string NounPhrase { get; } @@ -19,25 +22,30 @@ namespace StardewModdingAPI.Framework public DeprecationLevel Level { get; } /// <summary>The stack trace when the deprecation warning was raised.</summary> - public string StackTrace { get; } + public ImmutableStackTrace StackTrace { get; } + + /// <summary>Whether to log a stack trace showing where the deprecated code is in the mod.</summary> + public bool LogStackTrace { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modName">The affected mod's display name.</param> + /// <param name="mod">The affected mod.</param> /// <param name="nounPhrase">A noun phrase describing what is deprecated.</param> /// <param name="version">The SMAPI version which deprecated it.</param> /// <param name="level">The deprecation level for the affected code.</param> /// <param name="stackTrace">The stack trace when the deprecation warning was raised.</param> - public DeprecationWarning(string modName, string nounPhrase, string version, DeprecationLevel level, string stackTrace) + /// <param name="logStackTrace">Whether to log a stack trace showing where the deprecated code is in the mod.</param> + public DeprecationWarning(IModMetadata? mod, string nounPhrase, string version, DeprecationLevel level, ImmutableStackTrace stackTrace, bool logStackTrace) { - this.ModName = modName; + this.Mod = mod; this.NounPhrase = nounPhrase; this.Version = version; this.Level = level; this.StackTrace = stackTrace; + this.LogStackTrace = logStackTrace; } } } diff --git a/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs b/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs new file mode 100644 index 00000000..059d871c --- /dev/null +++ b/src/SMAPI/Framework/Deprecations/ImmutableStackTrace.cs @@ -0,0 +1,53 @@ +using System.Diagnostics; + +namespace StardewModdingAPI.Framework.Deprecations +{ + /// <summary>An immutable stack trace that caches its values.</summary> + internal class ImmutableStackTrace + { + /********* + ** Fields + *********/ + /// <summary>The underlying stack trace.</summary> + private readonly StackTrace StackTrace; + + /// <summary>The individual method calls in the stack trace.</summary> + private StackFrame[]? Frames; + + /// <summary>The string representation of the stack trace.</summary> + private string? StringForm; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="stackTrace">The underlying stack trace.</param> + public ImmutableStackTrace(StackTrace stackTrace) + { + this.StackTrace = stackTrace; + } + + /// <summary>Get the underlying frames.</summary> + /// <remarks>This is a reference to the underlying stack frames, so this array should not be edited.</remarks> + public StackFrame[] GetFrames() + { + return this.Frames ??= this.StackTrace.GetFrames(); + } + + /// <inheritdoc /> + public override string ToString() + { + return this.StringForm ??= this.StackTrace.ToString(); + } + + /// <summary>Get the current stack trace.</summary> + /// <param name="skipFrames">The number of frames up the stack from which to start the trace.</param> + public static ImmutableStackTrace Get(int skipFrames = 0) + { + return new ImmutableStackTrace( + new StackTrace(skipFrames: skipFrames + 1) // also skip this method + ); + } + } +} diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index dfc289ed..41540047 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -1,180 +1,192 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { /// <summary>Manages SMAPI events.</summary> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Private fields are deliberately named to simplify organisation.")] internal class EventManager { /********* ** Events *********/ /**** + ** Content + ****/ + /// <inheritdoc cref="IContentEvents.AssetRequested" /> + public readonly ManagedEvent<AssetRequestedEventArgs> AssetRequested; + + /// <inheritdoc cref="IContentEvents.AssetsInvalidated" /> + public readonly ManagedEvent<AssetsInvalidatedEventArgs> AssetsInvalidated; + + /// <inheritdoc cref="IContentEvents.AssetReady" /> + public readonly ManagedEvent<AssetReadyEventArgs> AssetReady; + + /// <inheritdoc cref="IContentEvents.LocaleChanged" /> + public readonly ManagedEvent<LocaleChangedEventArgs> LocaleChanged; + + + /**** ** Display ****/ - /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + /// <inheritdoc cref="IDisplayEvents.MenuChanged" /> public readonly ManagedEvent<MenuChangedEventArgs> MenuChanged; - /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + /// <inheritdoc cref="IDisplayEvents.Rendering" /> public readonly ManagedEvent<RenderingEventArgs> Rendering; - /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + /// <inheritdoc cref="IDisplayEvents.Rendered" /> public readonly ManagedEvent<RenderedEventArgs> Rendered; - /// <summary>Raised before the game world is drawn to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderingWorld" /> public readonly ManagedEvent<RenderingWorldEventArgs> RenderingWorld; - /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderedWorld" /> public readonly ManagedEvent<RenderedWorldEventArgs> RenderedWorld; - /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderingActiveMenu" /> public readonly ManagedEvent<RenderingActiveMenuEventArgs> RenderingActiveMenu; - /// <summary>When a menu is open (<see cref="StardewValley.Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderedActiveMenu" /> public readonly ManagedEvent<RenderedActiveMenuEventArgs> RenderedActiveMenu; - /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderingHud" /> public readonly ManagedEvent<RenderingHudEventArgs> RenderingHud; - /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents.RenderedHud" /> public readonly ManagedEvent<RenderedHudEventArgs> RenderedHud; - /// <summary>Raised after the game window is resized.</summary> + /// <inheritdoc cref="IDisplayEvents.WindowResized" /> public readonly ManagedEvent<WindowResizedEventArgs> WindowResized; /**** ** Game loop ****/ - /// <summary>Raised after the game is launched, right before the first update tick.</summary> + /// <inheritdoc cref="IGameLoopEvents.GameLaunched" /> public readonly ManagedEvent<GameLaunchedEventArgs> GameLaunched; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> + /// <inheritdoc cref="IGameLoopEvents.UpdateTicking" /> public readonly ManagedEvent<UpdateTickingEventArgs> UpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> + /// <inheritdoc cref="IGameLoopEvents.UpdateTicked" /> public readonly ManagedEvent<UpdateTickedEventArgs> UpdateTicked; - /// <summary>Raised once per second before the game performs its overall update tick.</summary> + /// <inheritdoc cref="IGameLoopEvents.OneSecondUpdateTicking" /> public readonly ManagedEvent<OneSecondUpdateTickingEventArgs> OneSecondUpdateTicking; - /// <summary>Raised once per second after the game performs its overall update tick.</summary> + /// <inheritdoc cref="IGameLoopEvents.OneSecondUpdateTicked" /> public readonly ManagedEvent<OneSecondUpdateTickedEventArgs> OneSecondUpdateTicked; - /// <summary>Raised before the game creates the save file.</summary> + /// <inheritdoc cref="IGameLoopEvents.SaveCreating" /> public readonly ManagedEvent<SaveCreatingEventArgs> SaveCreating; - /// <summary>Raised after the game finishes creating the save file.</summary> + /// <inheritdoc cref="IGameLoopEvents.SaveCreated" /> public readonly ManagedEvent<SaveCreatedEventArgs> SaveCreated; - /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + /// <inheritdoc cref="IGameLoopEvents.Saving" /> public readonly ManagedEvent<SavingEventArgs> Saving; - /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> + /// <inheritdoc cref="IGameLoopEvents.Saved" /> public readonly ManagedEvent<SavedEventArgs> Saved; - /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> + /// <inheritdoc cref="IGameLoopEvents.SaveLoaded" /> public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded; - /// <summary>Raised after the game begins a new day, including when loading a save.</summary> + /// <inheritdoc cref="IGameLoopEvents.DayStarted" /> public readonly ManagedEvent<DayStartedEventArgs> DayStarted; - /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="Saving"/>.</summary> + /// <inheritdoc cref="IGameLoopEvents.DayEnding" /> public readonly ManagedEvent<DayEndingEventArgs> DayEnding; - /// <summary>Raised after the in-game clock time changes.</summary> + /// <inheritdoc cref="IGameLoopEvents.TimeChanged" /> public readonly ManagedEvent<TimeChangedEventArgs> TimeChanged; - /// <summary>Raised after the game returns to the title screen.</summary> + /// <inheritdoc cref="IGameLoopEvents.ReturnedToTitle" /> public readonly ManagedEvent<ReturnedToTitleEventArgs> ReturnedToTitle; /**** ** Input ****/ - /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary> + /// <inheritdoc cref="IInputEvents.ButtonsChanged" /> public readonly ManagedEvent<ButtonsChangedEventArgs> ButtonsChanged; - /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> + /// <inheritdoc cref="IInputEvents.ButtonPressed" /> public readonly ManagedEvent<ButtonPressedEventArgs> ButtonPressed; - /// <summary>Raised after the player released a button on the keyboard, controller, or mouse.</summary> + /// <inheritdoc cref="IInputEvents.ButtonReleased" /> public readonly ManagedEvent<ButtonReleasedEventArgs> ButtonReleased; - /// <summary>Raised after the player moves the in-game cursor.</summary> + /// <inheritdoc cref="IInputEvents.CursorMoved" /> public readonly ManagedEvent<CursorMovedEventArgs> CursorMoved; - /// <summary>Raised after the player scrolls the mouse wheel.</summary> + /// <inheritdoc cref="IInputEvents.MouseWheelScrolled" /> public readonly ManagedEvent<MouseWheelScrolledEventArgs> MouseWheelScrolled; /**** ** Multiplayer ****/ - /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + /// <inheritdoc cref="IMultiplayerEvents.PeerContextReceived" /> public readonly ManagedEvent<PeerContextReceivedEventArgs> PeerContextReceived; - /// <summary>Raised after a peer connection is approved by the game.</summary> + /// <inheritdoc cref="IMultiplayerEvents.PeerConnected" /> public readonly ManagedEvent<PeerConnectedEventArgs> PeerConnected; - /// <summary>Raised after a mod message is received over the network.</summary> + /// <inheritdoc cref="IMultiplayerEvents.ModMessageReceived" /> public readonly ManagedEvent<ModMessageReceivedEventArgs> ModMessageReceived; - /// <summary>Raised after the connection with a peer is severed.</summary> + /// <inheritdoc cref="IMultiplayerEvents.PeerDisconnected" /> public readonly ManagedEvent<PeerDisconnectedEventArgs> PeerDisconnected; /**** ** Player ****/ - /// <summary>Raised after items are added or removed to a player's inventory.</summary> + /// <inheritdoc cref="IPlayerEvents.InventoryChanged" /> public readonly ManagedEvent<InventoryChangedEventArgs> InventoryChanged; - /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> + /// <inheritdoc cref="IPlayerEvents.LevelChanged" /> public readonly ManagedEvent<LevelChangedEventArgs> LevelChanged; - /// <summary>Raised after a player warps to a new location.</summary> + /// <inheritdoc cref="IPlayerEvents.Warped" /> public readonly ManagedEvent<WarpedEventArgs> Warped; /**** ** World ****/ - /// <summary>Raised after a game location is added or removed.</summary> + /// <inheritdoc cref="IWorldEvents.LocationListChanged" /> public readonly ManagedEvent<LocationListChangedEventArgs> LocationListChanged; - /// <summary>Raised after buildings are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.BuildingListChanged" /> public readonly ManagedEvent<BuildingListChangedEventArgs> BuildingListChanged; - /// <summary>Raised after debris are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.DebrisListChanged" /> public readonly ManagedEvent<DebrisListChangedEventArgs> DebrisListChanged; - /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.LargeTerrainFeatureListChanged" /> public readonly ManagedEvent<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged; - /// <summary>Raised after NPCs are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.NpcListChanged" /> public readonly ManagedEvent<NpcListChangedEventArgs> NpcListChanged; - /// <summary>Raised after objects are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.ObjectListChanged" /> public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged; - /// <summary>Raised after items are added or removed from a chest.</summary> + /// <inheritdoc cref="IWorldEvents.ChestInventoryChanged" /> public readonly ManagedEvent<ChestInventoryChangedEventArgs> ChestInventoryChanged; - /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.TerrainFeatureListChanged" /> public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; - /// <summary>Raised after furniture are added or removed in a location.</summary> + /// <inheritdoc cref="IWorldEvents.FurnitureListChanged" /> public readonly ManagedEvent<FurnitureListChangedEventArgs> FurnitureListChanged; /**** ** Specialized ****/ - /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecializedEvents.LoadStageChanged"/>.</summary> + /// <inheritdoc cref="ISpecializedEvents.LoadStageChanged" /> public readonly ManagedEvent<LoadStageChangedEventArgs> LoadStageChanged; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/>.</summary> + /// <inheritdoc cref="ISpecializedEvents.UnvalidatedUpdateTicking" /> public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/>.</summary> + /// <inheritdoc cref="ISpecializedEvents.UnvalidatedUpdateTicked" /> public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; @@ -191,7 +203,12 @@ namespace StardewModdingAPI.Framework.Events return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", modRegistry, isPerformanceCritical); } - // init events (new) + // init events + this.AssetRequested = ManageEventOf<AssetRequestedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetRequested)); + this.AssetsInvalidated = ManageEventOf<AssetsInvalidatedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetsInvalidated)); + this.AssetReady = ManageEventOf<AssetReadyEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.AssetReady)); + this.LocaleChanged = ManageEventOf<LocaleChangedEventArgs>(nameof(IModEvents.Content), nameof(IContentEvents.LocaleChanged)); + this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true); this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true); @@ -247,12 +264,5 @@ namespace StardewModdingAPI.Framework.Events this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true); this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true); } - - /// <summary>Get all managed events.</summary> - public IEnumerable<IManagedEvent> GetAllEvents() - { - foreach (FieldInfo field in this.GetType().GetFields()) - yield return (IManagedEvent)field.GetValue(this); - } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index fa20a079..4b8a770d 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.Events protected readonly ModRegistry ModRegistry; /// <summary>The underlying event handlers.</summary> - private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new List<ManagedEventHandler<TEventArgs>>(); + private readonly List<ManagedEventHandler<TEventArgs>> Handlers = new(); /// <summary>A cached snapshot of <see cref="Handlers"/>, or <c>null</c> to rebuild it next raise.</summary> - private ManagedEventHandler<TEventArgs>[] CachedHandlers = new ManagedEventHandler<TEventArgs>[0]; + private ManagedEventHandler<TEventArgs>[]? CachedHandlers = Array.Empty<ManagedEventHandler<TEventArgs>>(); /// <summary>The total number of event handlers registered for this events, regardless of whether they're still registered.</summary> private int RegistrationIndex; @@ -33,10 +33,10 @@ namespace StardewModdingAPI.Framework.Events /********* ** Accessors *********/ - /// <summary>A human-readable name for the event.</summary> + /// <inheritdoc /> public string EventName { get; } - /// <summary>Whether the event is typically called at least once per second.</summary> + /// <inheritdoc /> public bool IsPerformanceCritical { get; } @@ -98,7 +98,15 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raise the event and notify all handlers.</summary> /// <param name="args">The event arguments to pass.</param> /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> - public void Raise(TEventArgs args, Func<IModMetadata, bool> match = null) + public void Raise(TEventArgs args, Func<IModMetadata, bool>? match = null) + { + this.Raise((_, invoke) => invoke(args), match); + } + + /// <summary>Raise the event and notify all handlers.</summary> + /// <param name="invoke">Invoke an event handler. This receives the mod which registered the handler, and should invoke the callback with the event arguments to pass it.</param> + /// <param name="match">A lambda which returns true if the event should be raised for the given mod.</param> + public void Raise(Action<IModMetadata, Action<TEventArgs>> invoke, Func<IModMetadata, bool>? match = null) { // skip if no handlers if (this.Handlers.Count == 0) @@ -128,7 +136,7 @@ namespace StardewModdingAPI.Framework.Events try { - handler.Handler.Invoke(null, args); + invoke(handler.SourceMod, args => handler.Handler.Invoke(null, args)); } catch (Exception ex) { diff --git a/src/SMAPI/Framework/Events/ManagedEventHandler.cs b/src/SMAPI/Framework/Events/ManagedEventHandler.cs index b32a2a22..d32acdb9 100644 --- a/src/SMAPI/Framework/Events/ManagedEventHandler.cs +++ b/src/SMAPI/Framework/Events/ManagedEventHandler.cs @@ -39,12 +39,10 @@ namespace StardewModdingAPI.Framework.Events this.SourceMod = sourceMod; } - /// <summary>Compares the current instance with another object of the same type and returns an integer that indicates whether the current instance precedes, follows, or occurs in the same position in the sort order as the other object.</summary> - /// <param name="obj">An object to compare with this instance.</param> - /// <exception cref="T:System.ArgumentException"><paramref name="obj" /> is not the same type as this instance.</exception> - public int CompareTo(object obj) + /// <inheritdoc /> + public int CompareTo(object? obj) { - if (!(obj is ManagedEventHandler<TEventArgs> other)) + if (obj is not ManagedEventHandler<TEventArgs> other) throw new ArgumentException("Can't compare to an unrelated object type."); int priorityCompare = -this.Priority.CompareTo(other.Priority); // higher value = sort first diff --git a/src/SMAPI/Framework/Events/ModContentEvents.cs b/src/SMAPI/Framework/Events/ModContentEvents.cs new file mode 100644 index 00000000..beb96031 --- /dev/null +++ b/src/SMAPI/Framework/Events/ModContentEvents.cs @@ -0,0 +1,50 @@ +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI.Framework.Events +{ + /// <inheritdoc cref="IContentEvents" /> + internal class ModContentEvents : ModEventsBase, IContentEvents + { + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public event EventHandler<AssetRequestedEventArgs> AssetRequested + { + add => this.EventManager.AssetRequested.Add(value, this.Mod); + remove => this.EventManager.AssetRequested.Remove(value); + } + + /// <inheritdoc /> + public event EventHandler<AssetsInvalidatedEventArgs> AssetsInvalidated + { + add => this.EventManager.AssetsInvalidated.Add(value, this.Mod); + remove => this.EventManager.AssetsInvalidated.Remove(value); + } + + /// <inheritdoc /> + public event EventHandler<AssetReadyEventArgs> AssetReady + { + add => this.EventManager.AssetReady.Add(value, this.Mod); + remove => this.EventManager.AssetReady.Remove(value); + } + + /// <inheritdoc /> + public event EventHandler<LocaleChangedEventArgs> LocaleChanged + { + add => this.EventManager.LocaleChanged.Add(value, this.Mod); + remove => this.EventManager.LocaleChanged.Remove(value); + } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod which uses this instance.</param> + /// <param name="eventManager">The underlying event manager.</param> + internal ModContentEvents(IModMetadata mod, EventManager eventManager) + : base(mod, eventManager) { } + } +} diff --git a/src/SMAPI/Framework/Events/ModDisplayEvents.cs b/src/SMAPI/Framework/Events/ModDisplayEvents.cs index 54d40dee..48f55324 100644 --- a/src/SMAPI/Framework/Events/ModDisplayEvents.cs +++ b/src/SMAPI/Framework/Events/ModDisplayEvents.cs @@ -1,79 +1,78 @@ using System; using StardewModdingAPI.Events; -using StardewValley; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events related to UI and drawing to the screen.</summary> + /// <inheritdoc cref="IDisplayEvents" /> internal class ModDisplayEvents : ModEventsBase, IDisplayEvents { /********* ** Accessors *********/ - /// <summary>Raised after a game menu is opened, closed, or replaced.</summary> + /// <inheritdoc /> public event EventHandler<MenuChangedEventArgs> MenuChanged { add => this.EventManager.MenuChanged.Add(value, this.Mod); remove => this.EventManager.MenuChanged.Remove(value); } - /// <summary>Raised before the game draws anything to the screen in a draw tick, as soon as the sprite batch is opened. The sprite batch may be closed and reopened multiple times after this event is called, but it's only raised once per draw tick. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + /// <inheritdoc /> public event EventHandler<RenderingEventArgs> Rendering { add => this.EventManager.Rendering.Add(value, this.Mod); remove => this.EventManager.Rendering.Remove(value); } - /// <summary>Raised after the game draws to the sprite patch in a draw tick, just before the final sprite batch is rendered to the screen. Since the game may open/close the sprite batch multiple times in a draw tick, the sprite batch may not contain everything being drawn and some things may already be rendered to the screen. Content drawn to the sprite batch at this point will be drawn over all vanilla content (including menus, HUD, and cursor).</summary> + /// <inheritdoc /> public event EventHandler<RenderedEventArgs> Rendered { add => this.EventManager.Rendered.Add(value, this.Mod); remove => this.EventManager.Rendered.Remove(value); } - /// <summary>Raised before the game world is drawn to the screen. This event isn't useful for drawing to the screen, since the game will draw over it.</summary> + /// <inheritdoc /> public event EventHandler<RenderingWorldEventArgs> RenderingWorld { add => this.EventManager.RenderingWorld.Add(value, this.Mod); remove => this.EventManager.RenderingWorld.Remove(value); } - /// <summary>Raised after the game world is drawn to the sprite patch, before it's rendered to the screen. Content drawn to the sprite batch at this point will be drawn over the world, but under any active menu, HUD elements, or cursor.</summary> + /// <inheritdoc /> public event EventHandler<RenderedWorldEventArgs> RenderedWorld { add => this.EventManager.RenderedWorld.Add(value, this.Mod); remove => this.EventManager.RenderedWorld.Remove(value); } - /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised before that menu is drawn to the screen. This includes the game's internal menus like the title screen. Content drawn to the sprite batch at this point will appear under the menu.</summary> + /// <inheritdoc /> public event EventHandler<RenderingActiveMenuEventArgs> RenderingActiveMenu { add => this.EventManager.RenderingActiveMenu.Add(value, this.Mod); remove => this.EventManager.RenderingActiveMenu.Remove(value); } - /// <summary>When a menu is open (<see cref="Game1.activeClickableMenu"/> isn't null), raised after that menu is drawn to the sprite batch but before it's rendered to the screen. Content drawn to the sprite batch at this point will appear over the menu and menu cursor.</summary> + /// <inheritdoc /> public event EventHandler<RenderedActiveMenuEventArgs> RenderedActiveMenu { add => this.EventManager.RenderedActiveMenu.Add(value, this.Mod); remove => this.EventManager.RenderedActiveMenu.Remove(value); } - /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear under the HUD.</summary> + /// <inheritdoc /> public event EventHandler<RenderingHudEventArgs> RenderingHud { add => this.EventManager.RenderingHud.Add(value, this.Mod); remove => this.EventManager.RenderingHud.Remove(value); } - /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the sprite batch, but before it's rendered to the screen. The vanilla HUD may be hidden at this point (e.g. because a menu is open). Content drawn to the sprite batch at this point will appear over the HUD.</summary> + /// <inheritdoc /> public event EventHandler<RenderedHudEventArgs> RenderedHud { add => this.EventManager.RenderedHud.Add(value, this.Mod); remove => this.EventManager.RenderedHud.Remove(value); } - /// <summary>Raised after the game window is resized.</summary> + /// <inheritdoc /> public event EventHandler<WindowResizedEventArgs> WindowResized { add => this.EventManager.WindowResized.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs index 1d1c92c6..1fb3482c 100644 --- a/src/SMAPI/Framework/Events/ModEvents.cs +++ b/src/SMAPI/Framework/Events/ModEvents.cs @@ -2,31 +2,34 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Manages access to events raised by SMAPI.</summary> + /// <inheritdoc /> internal class ModEvents : IModEvents { /********* ** Accessors *********/ - /// <summary>Events related to UI and drawing to the screen.</summary> + /// <inheritdoc /> + public IContentEvents Content { get; } + + /// <inheritdoc /> public IDisplayEvents Display { get; } - /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IModEvents.Input"/> if possible.</summary> + /// <inheritdoc /> public IGameLoopEvents GameLoop { get; } - /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> + /// <inheritdoc /> public IInputEvents Input { get; } - /// <summary>Events raised for multiplayer messages and connections.</summary> + /// <inheritdoc /> public IMultiplayerEvents Multiplayer { get; } - /// <summary>Events raised when the player data changes.</summary> + /// <inheritdoc /> public IPlayerEvents Player { get; } - /// <summary>Events raised when something changes in the world.</summary> + /// <inheritdoc /> public IWorldEvents World { get; } - /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + /// <inheritdoc /> public ISpecializedEvents Specialized { get; } @@ -38,6 +41,7 @@ namespace StardewModdingAPI.Framework.Events /// <param name="eventManager">The underlying event manager.</param> public ModEvents(IModMetadata mod, EventManager eventManager) { + this.Content = new ModContentEvents(mod, eventManager); this.Display = new ModDisplayEvents(mod, eventManager); this.GameLoop = new ModGameLoopEvents(mod, eventManager); this.Input = new ModInputEvents(mod, eventManager); diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index 1150d641..5f0db369 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -3,104 +3,104 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events linked to the game's update loop. The update loop runs roughly ≈60 times/second to run game logic like state changes, action handling, etc. These can be useful, but you should consider more semantic events like <see cref="IInputEvents"/> if possible.</summary> + /// <inheritdoc cref="IGameLoopEvents" /> internal class ModGameLoopEvents : ModEventsBase, IGameLoopEvents { /********* ** Accessors *********/ - /// <summary>Raised after the game is launched, right before the first update tick.</summary> + /// <inheritdoc /> public event EventHandler<GameLaunchedEventArgs> GameLaunched { add => this.EventManager.GameLaunched.Add(value, this.Mod); remove => this.EventManager.GameLaunched.Remove(value); } - /// <summary>Raised before the game performs its overall update tick (≈60 times per second).</summary> + /// <inheritdoc /> public event EventHandler<UpdateTickingEventArgs> UpdateTicking { add => this.EventManager.UpdateTicking.Add(value, this.Mod); remove => this.EventManager.UpdateTicking.Remove(value); } - /// <summary>Raised after the game performs its overall update tick (≈60 times per second).</summary> + /// <inheritdoc /> public event EventHandler<UpdateTickedEventArgs> UpdateTicked { add => this.EventManager.UpdateTicked.Add(value, this.Mod); remove => this.EventManager.UpdateTicked.Remove(value); } - /// <summary>Raised once per second before the game state is updated.</summary> + /// <inheritdoc /> public event EventHandler<OneSecondUpdateTickingEventArgs> OneSecondUpdateTicking { add => this.EventManager.OneSecondUpdateTicking.Add(value, this.Mod); remove => this.EventManager.OneSecondUpdateTicking.Remove(value); } - /// <summary>Raised once per second after the game state is updated.</summary> + /// <inheritdoc /> public event EventHandler<OneSecondUpdateTickedEventArgs> OneSecondUpdateTicked { add => this.EventManager.OneSecondUpdateTicked.Add(value, this.Mod); remove => this.EventManager.OneSecondUpdateTicked.Remove(value); } - /// <summary>Raised before the game creates a new save file.</summary> + /// <inheritdoc /> public event EventHandler<SaveCreatingEventArgs> SaveCreating { add => this.EventManager.SaveCreating.Add(value, this.Mod); remove => this.EventManager.SaveCreating.Remove(value); } - /// <summary>Raised after the game finishes creating the save file.</summary> + /// <inheritdoc /> public event EventHandler<SaveCreatedEventArgs> SaveCreated { add => this.EventManager.SaveCreated.Add(value, this.Mod); remove => this.EventManager.SaveCreated.Remove(value); } - /// <summary>Raised before the game begins writes data to the save file.</summary> + /// <inheritdoc /> public event EventHandler<SavingEventArgs> Saving { add => this.EventManager.Saving.Add(value, this.Mod); remove => this.EventManager.Saving.Remove(value); } - /// <summary>Raised after the game finishes writing data to the save file.</summary> + /// <inheritdoc /> public event EventHandler<SavedEventArgs> Saved { add => this.EventManager.Saved.Add(value, this.Mod); remove => this.EventManager.Saved.Remove(value); } - /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> + /// <inheritdoc /> public event EventHandler<SaveLoadedEventArgs> SaveLoaded { add => this.EventManager.SaveLoaded.Add(value, this.Mod); remove => this.EventManager.SaveLoaded.Remove(value); } - /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> + /// <inheritdoc /> public event EventHandler<DayStartedEventArgs> DayStarted { add => this.EventManager.DayStarted.Add(value, this.Mod); remove => this.EventManager.DayStarted.Remove(value); } - /// <summary>Raised before the game ends the current day. This happens before it starts setting up the next day and before <see cref="IGameLoopEvents.Saving"/>.</summary> + /// <inheritdoc /> public event EventHandler<DayEndingEventArgs> DayEnding { add => this.EventManager.DayEnding.Add(value, this.Mod); remove => this.EventManager.DayEnding.Remove(value); } - /// <summary>Raised after the in-game clock time changes.</summary> + /// <inheritdoc /> public event EventHandler<TimeChangedEventArgs> TimeChanged { add => this.EventManager.TimeChanged.Add(value, this.Mod); remove => this.EventManager.TimeChanged.Remove(value); } - /// <summary>Raised after the game returns to the title screen.</summary> + /// <inheritdoc /> public event EventHandler<ReturnedToTitleEventArgs> ReturnedToTitle { add => this.EventManager.ReturnedToTitle.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModInputEvents.cs b/src/SMAPI/Framework/Events/ModInputEvents.cs index 6f423e5d..40edf806 100644 --- a/src/SMAPI/Framework/Events/ModInputEvents.cs +++ b/src/SMAPI/Framework/Events/ModInputEvents.cs @@ -3,41 +3,41 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events raised when the player provides input using a controller, keyboard, or mouse.</summary> + /// <inheritdoc cref="IInputEvents" /> internal class ModInputEvents : ModEventsBase, IInputEvents { /********* ** Accessors *********/ - /// <summary>Raised after the player presses or releases any buttons on the keyboard, controller, or mouse.</summary> + /// <inheritdoc /> public event EventHandler<ButtonsChangedEventArgs> ButtonsChanged { add => this.EventManager.ButtonsChanged.Add(value, this.Mod); remove => this.EventManager.ButtonsChanged.Remove(value); } - /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> + /// <inheritdoc /> public event EventHandler<ButtonPressedEventArgs> ButtonPressed { add => this.EventManager.ButtonPressed.Add(value, this.Mod); remove => this.EventManager.ButtonPressed.Remove(value); } - /// <summary>Raised after the player releases a button on the keyboard, controller, or mouse.</summary> + /// <inheritdoc /> public event EventHandler<ButtonReleasedEventArgs> ButtonReleased { add => this.EventManager.ButtonReleased.Add(value, this.Mod); remove => this.EventManager.ButtonReleased.Remove(value); } - /// <summary>Raised after the player moves the in-game cursor.</summary> + /// <inheritdoc /> public event EventHandler<CursorMovedEventArgs> CursorMoved { add => this.EventManager.CursorMoved.Add(value, this.Mod); remove => this.EventManager.CursorMoved.Remove(value); } - /// <summary>Raised after the player scrolls the mouse wheel.</summary> + /// <inheritdoc /> public event EventHandler<MouseWheelScrolledEventArgs> MouseWheelScrolled { add => this.EventManager.MouseWheelScrolled.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs index 2f9b9482..b90f64fa 100644 --- a/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs +++ b/src/SMAPI/Framework/Events/ModMultiplayerEvents.cs @@ -3,34 +3,34 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events raised for multiplayer messages and connections.</summary> + /// <inheritdoc cref="IMultiplayerEvents" /> internal class ModMultiplayerEvents : ModEventsBase, IMultiplayerEvents { /********* ** Accessors *********/ - /// <summary>Raised after the mod context for a peer is received. This happens before the game approves the connection (<see cref="IMultiplayerEvents.PeerConnected"/>), so the player doesn't yet exist in the game. This is the earliest point where messages can be sent to the peer via SMAPI.</summary> + /// <inheritdoc /> public event EventHandler<PeerContextReceivedEventArgs> PeerContextReceived { add => this.EventManager.PeerContextReceived.Add(value, this.Mod); remove => this.EventManager.PeerContextReceived.Remove(value); } - /// <summary>Raised after a peer connection is approved by the game.</summary> + /// <inheritdoc /> public event EventHandler<PeerConnectedEventArgs> PeerConnected { add => this.EventManager.PeerConnected.Add(value, this.Mod); remove => this.EventManager.PeerConnected.Remove(value); } - /// <summary>Raised after a mod message is received over the network.</summary> + /// <inheritdoc /> public event EventHandler<ModMessageReceivedEventArgs> ModMessageReceived { add => this.EventManager.ModMessageReceived.Add(value, this.Mod); remove => this.EventManager.ModMessageReceived.Remove(value); } - /// <summary>Raised after the connection with a peer is severed.</summary> + /// <inheritdoc /> public event EventHandler<PeerDisconnectedEventArgs> PeerDisconnected { add => this.EventManager.PeerDisconnected.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModPlayerEvents.cs b/src/SMAPI/Framework/Events/ModPlayerEvents.cs index 240beb8d..b2d89e9a 100644 --- a/src/SMAPI/Framework/Events/ModPlayerEvents.cs +++ b/src/SMAPI/Framework/Events/ModPlayerEvents.cs @@ -3,27 +3,27 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events raised when the player data changes.</summary> + /// <inheritdoc cref="IPlayerEvents" /> internal class ModPlayerEvents : ModEventsBase, IPlayerEvents { /********* ** Accessors *********/ - /// <summary>Raised after items are added or removed to a player's inventory. NOTE: this event is currently only raised for the local player.</summary> + /// <inheritdoc /> public event EventHandler<InventoryChangedEventArgs> InventoryChanged { add => this.EventManager.InventoryChanged.Add(value, this.Mod); remove => this.EventManager.InventoryChanged.Remove(value); } - /// <summary>Raised after a player skill level changes. This happens as soon as they level up, not when the game notifies the player after their character goes to bed. NOTE: this event is currently only raised for the local player.</summary> + /// <inheritdoc /> public event EventHandler<LevelChangedEventArgs> LevelChanged { add => this.EventManager.LevelChanged.Add(value, this.Mod); remove => this.EventManager.LevelChanged.Remove(value); } - /// <summary>Raised after a player warps to a new location. NOTE: this event is currently only raised for the local player.</summary> + /// <inheritdoc /> public event EventHandler<WarpedEventArgs> Warped { add => this.EventManager.Warped.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs index 1d6788e1..7980208b 100644 --- a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -3,27 +3,27 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + /// <inheritdoc cref="ISpecializedEvents" /> internal class ModSpecializedEvents : ModEventsBase, ISpecializedEvents { /********* ** Accessors *********/ - /// <summary>Raised when the low-level stage in the game's loading process has changed. This is an advanced event for mods which need to run code at specific points in the loading process. The available stages or when they happen might change without warning in future versions (e.g. due to changes in the game's load process), so mods using this event are more likely to break or have bugs. Most mods should use <see cref="IGameLoopEvents"/> instead.</summary> + /// <inheritdoc /> public event EventHandler<LoadStageChangedEventArgs> LoadStageChanged { add => this.EventManager.LoadStageChanged.Add(value, this.Mod); remove => this.EventManager.LoadStageChanged.Remove(value); } - /// <summary>Raised before the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + /// <inheritdoc /> public event EventHandler<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking { add => this.EventManager.UnvalidatedUpdateTicking.Add(value, this.Mod); remove => this.EventManager.UnvalidatedUpdateTicking.Remove(value); } - /// <summary>Raised after the game state is updated (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this event will trigger a stability warning in the SMAPI console.</summary> + /// <inheritdoc /> public event EventHandler<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked { add => this.EventManager.UnvalidatedUpdateTicked.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs index f4c40abc..a7b7d799 100644 --- a/src/SMAPI/Framework/Events/ModWorldEvents.cs +++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs @@ -3,69 +3,69 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events raised when something changes in the world.</summary> + /// <inheritdoc cref="IWorldEvents" /> internal class ModWorldEvents : ModEventsBase, IWorldEvents { /********* ** Accessors *********/ - /// <summary>Raised after a game location is added or removed.</summary> + /// <inheritdoc /> public event EventHandler<LocationListChangedEventArgs> LocationListChanged { add => this.EventManager.LocationListChanged.Add(value, this.Mod); remove => this.EventManager.LocationListChanged.Remove(value); } - /// <summary>Raised after buildings are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<BuildingListChangedEventArgs> BuildingListChanged { add => this.EventManager.BuildingListChanged.Add(value, this.Mod); remove => this.EventManager.BuildingListChanged.Remove(value); } - /// <summary>Raised after debris are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<DebrisListChangedEventArgs> DebrisListChanged { add => this.EventManager.DebrisListChanged.Add(value, this.Mod); remove => this.EventManager.DebrisListChanged.Remove(value); } - /// <summary>Raised after large terrain features (like bushes) are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<LargeTerrainFeatureListChangedEventArgs> LargeTerrainFeatureListChanged { add => this.EventManager.LargeTerrainFeatureListChanged.Add(value, this.Mod); remove => this.EventManager.LargeTerrainFeatureListChanged.Remove(value); } - /// <summary>Raised after NPCs are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<NpcListChangedEventArgs> NpcListChanged { add => this.EventManager.NpcListChanged.Add(value, this.Mod); remove => this.EventManager.NpcListChanged.Remove(value); } - /// <summary>Raised after objects are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<ObjectListChangedEventArgs> ObjectListChanged { add => this.EventManager.ObjectListChanged.Add(value, this.Mod); remove => this.EventManager.ObjectListChanged.Remove(value); } - /// <summary>Raised after items are added or removed from a chest.</summary> + /// <inheritdoc /> public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged { add => this.EventManager.ChestInventoryChanged.Add(value, this.Mod); remove => this.EventManager.ChestInventoryChanged.Remove(value); } - /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged { add => this.EventManager.TerrainFeatureListChanged.Add(value, this.Mod); remove => this.EventManager.TerrainFeatureListChanged.Remove(value); } - /// <summary>Raised after furniture are added or removed in a location.</summary> + /// <inheritdoc /> public event EventHandler<FurnitureListChangedEventArgs> FurnitureListChanged { add => this.EventManager.FurnitureListChanged.Add(value, this.Mod); diff --git a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs index 85d85e3d..be1fe748 100644 --- a/src/SMAPI/Framework/Exceptions/SContentLoadException.cs +++ b/src/SMAPI/Framework/Exceptions/SContentLoadException.cs @@ -1,4 +1,4 @@ -using System; +using System; using Microsoft.Xna.Framework.Content; namespace StardewModdingAPI.Framework.Exceptions @@ -12,7 +12,7 @@ namespace StardewModdingAPI.Framework.Exceptions /// <summary>Construct an instance.</summary> /// <param name="message">The error message.</param> /// <param name="ex">The underlying exception, if any.</param> - public SContentLoadException(string message, Exception ex = null) + public SContentLoadException(string message, Exception? ex = null) : base(message, ex) { } } } diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index b69c6757..542c1345 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework private static string GetSemanticVersionString(string gameVersion) { // mapped version - return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion) + return GameVersion.VersionMap.TryGetValue(gameVersion, out string? semanticVersion) ? semanticVersion : gameVersion; } @@ -62,10 +62,10 @@ namespace StardewModdingAPI.Framework /// <param name="semanticVersion">The semantic version string.</param> private static string GetGameVersionString(string semanticVersion) { - foreach (var mapping in GameVersion.VersionMap) + foreach ((string gameVersion, string equivalentSemanticVersion) in GameVersion.VersionMap) { - if (mapping.Value.Equals(semanticVersion, StringComparison.OrdinalIgnoreCase)) - return mapping.Key; + if (equivalentSemanticVersion.Equals(semanticVersion, StringComparison.OrdinalIgnoreCase)) + return gameVersion; } return semanticVersion; diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index cb876ee4..7cee20b9 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework string RelativeDirectoryPath { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> - ModDataRecordVersionedFields DataRecord { get; } + ModDataRecordVersionedFields? DataRecord { get; } /// <summary>The metadata resolution status.</summary> ModMetadataStatus Status { get; } @@ -39,31 +39,31 @@ namespace StardewModdingAPI.Framework ModWarning Warnings { get; } /// <summary>The reason the metadata is invalid, if any.</summary> - string Error { get; } + string? Error { get; } /// <summary>A detailed technical message for <see cref="Error"/>, if any.</summary> - public string ErrorDetails { get; } + string? ErrorDetails { get; } /// <summary>Whether the mod folder should be ignored. This is <c>true</c> if it was found within a folder whose name starts with a dot.</summary> bool IsIgnored { get; } /// <summary>The mod instance (if loaded and <see cref="IModInfo.IsContentPack"/> is false).</summary> - IMod Mod { get; } + IMod? Mod { get; } /// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary> - IContentPack ContentPack { get; } + IContentPack? ContentPack { get; } /// <summary>The translations for this mod (if loaded).</summary> - TranslationHelper Translations { get; } + TranslationHelper? Translations { get; } /// <summary>Writes messages to the console and log file as this mod.</summary> - IMonitor Monitor { get; } + IMonitor? Monitor { get; } /// <summary>The mod-provided API (if any).</summary> - object Api { get; } + object? Api { get; } /// <summary>The update-check metadata for this mod (if any).</summary> - ModEntryModel UpdateCheckData { get; } + ModEntryModel? UpdateCheckData { get; } /// <summary>The fake content packs created by this mod, if any.</summary> ISet<WeakReference<ContentPack>> FakeContentPacks { get; } @@ -82,7 +82,7 @@ namespace StardewModdingAPI.Framework /// <param name="error">The reason the metadata is invalid, if any.</param> /// <param name="errorDetails">A detailed technical message, if any.</param> /// <returns>Return the instance for chaining.</returns> - IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null); + IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string? error, string? errorDetails = null); /// <summary>Set a warning flag for the mod.</summary> /// <param name="warning">The warning to set.</param> @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework /// <summary>Set the mod-provided API instance.</summary> /// <param name="api">The mod-provided API.</param> - IModMetadata SetApi(object api); + IModMetadata SetApi(object? api); /// <summary>Set the update-check metadata for this mod.</summary> /// <param name="data">The update-check metadata.</param> @@ -115,7 +115,7 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the mod has the given ID.</summary> /// <param name="id">The mod ID to check.</param> - bool HasID(string id); + bool HasID(string? id); /// <summary>Get the defined update keys.</summary> /// <param name="validOnly">Only return valid update keys.</param> diff --git a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs index b0bb7f80..4ac3332c 100644 --- a/src/SMAPI/Framework/Input/GamePadStateBuilder.cs +++ b/src/SMAPI/Framework/Input/GamePadStateBuilder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Input; @@ -21,7 +22,7 @@ namespace StardewModdingAPI.Framework.Input private GamePadState? State; /// <summary>The current button states.</summary> - private readonly IDictionary<SButton, ButtonState> ButtonStates; + private readonly IDictionary<SButton, ButtonState>? ButtonStates; /// <summary>The left trigger value.</summary> private float LeftTrigger; @@ -40,6 +41,7 @@ namespace StardewModdingAPI.Framework.Input ** Accessors *********/ /// <summary>Whether the gamepad is currently connected.</summary> + [MemberNotNullWhen(true, nameof(GamePadStateBuilder.ButtonStates))] public bool IsConnected { get; } @@ -85,8 +87,7 @@ namespace StardewModdingAPI.Framework.Input this.RightStickPos = sticks.Right; } - /// <summary>Override the states for a set of buttons.</summary> - /// <param name="overrides">The button state overrides.</param> + /// <inheritdoc /> public GamePadStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides) { if (!this.IsConnected) @@ -104,10 +105,10 @@ namespace StardewModdingAPI.Framework.Input this.LeftStickPos.Y = isDown ? 1 : 0; break; case SButton.LeftThumbstickDown: - this.LeftStickPos.Y = isDown ? 1 : 0; + this.LeftStickPos.Y = isDown ? -1 : 0; break; case SButton.LeftThumbstickLeft: - this.LeftStickPos.X = isDown ? 1 : 0; + this.LeftStickPos.X = isDown ? -1 : 0; break; case SButton.LeftThumbstickRight: this.LeftStickPos.X = isDown ? 1 : 0; @@ -118,10 +119,10 @@ namespace StardewModdingAPI.Framework.Input this.RightStickPos.Y = isDown ? 1 : 0; break; case SButton.RightThumbstickDown: - this.RightStickPos.Y = isDown ? 1 : 0; + this.RightStickPos.Y = isDown ? -1 : 0; break; case SButton.RightThumbstickLeft: - this.RightStickPos.X = isDown ? 1 : 0; + this.RightStickPos.X = isDown ? -1 : 0; break; case SButton.RightThumbstickRight: this.RightStickPos.X = isDown ? 1 : 0; @@ -151,7 +152,7 @@ namespace StardewModdingAPI.Framework.Input return this; } - /// <summary>Get the currently pressed buttons.</summary> + /// <inheritdoc /> public IEnumerable<SButton> GetPressedButtons() { if (!this.IsConnected) @@ -191,7 +192,7 @@ namespace StardewModdingAPI.Framework.Input } } - /// <summary>Get the equivalent state.</summary> + /// <inheritdoc /> public GamePadState GetState() { this.State ??= new GamePadState( @@ -212,6 +213,9 @@ namespace StardewModdingAPI.Framework.Input /// <summary>Get the pressed gamepad buttons.</summary> private IEnumerable<Buttons> GetPressedGamePadButtons() { + if (!this.IsConnected) + yield break; + foreach (var pair in this.ButtonStates) { if (pair.Value == ButtonState.Pressed && pair.Key.TryGetController(out Buttons button)) diff --git a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs index 620ad442..f66fbd07 100644 --- a/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs +++ b/src/SMAPI/Framework/Input/KeyboardStateBuilder.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.Input private KeyboardState? State; /// <summary>The pressed buttons.</summary> - private readonly HashSet<Keys> PressedButtons = new HashSet<Keys>(); + private readonly HashSet<Keys> PressedButtons = new(); /********* @@ -27,12 +27,11 @@ namespace StardewModdingAPI.Framework.Input this.State = state; this.PressedButtons.Clear(); - foreach (var button in state.GetPressedKeys()) + foreach (Keys button in state.GetPressedKeys()) this.PressedButtons.Add(button); } - /// <summary>Override the states for a set of buttons.</summary> - /// <param name="overrides">The button state overrides.</param> + /// <inheritdoc /> public KeyboardStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides) { foreach (var pair in overrides) @@ -51,14 +50,14 @@ namespace StardewModdingAPI.Framework.Input return this; } - /// <summary>Get the currently pressed buttons.</summary> + /// <inheritdoc /> public IEnumerable<SButton> GetPressedButtons() { foreach (Keys key in this.PressedButtons) yield return key.ToSButton(); } - /// <summary>Get the equivalent state.</summary> + /// <inheritdoc /> public KeyboardState GetState() { return diff --git a/src/SMAPI/Framework/Input/MouseStateBuilder.cs b/src/SMAPI/Framework/Input/MouseStateBuilder.cs index a1ac5492..c2a0891b 100644 --- a/src/SMAPI/Framework/Input/MouseStateBuilder.cs +++ b/src/SMAPI/Framework/Input/MouseStateBuilder.cs @@ -51,8 +51,7 @@ namespace StardewModdingAPI.Framework.Input this.ScrollWheelValue = state.ScrollWheelValue; } - /// <summary>Override the states for a set of buttons.</summary> - /// <param name="overrides">The button state overrides.</param> + /// <inheritdoc /> public MouseStateBuilder OverrideButtons(IDictionary<SButton, SButtonState> overrides) { foreach (var pair in overrides) @@ -67,7 +66,7 @@ namespace StardewModdingAPI.Framework.Input return this; } - /// <summary>Get the currently pressed buttons.</summary> + /// <inheritdoc /> public IEnumerable<SButton> GetPressedButtons() { foreach (var pair in this.ButtonStates) @@ -77,7 +76,7 @@ namespace StardewModdingAPI.Framework.Input } } - /// <summary>Get the equivalent state.</summary> + /// <inheritdoc /> public MouseState GetState() { this.State ??= new MouseState( diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index a8d1f371..fef83af7 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -15,16 +15,16 @@ namespace StardewModdingAPI.Framework.Input ** Accessors *********/ /// <summary>The cursor position on the screen adjusted for the zoom level.</summary> - private CursorPosition CursorPositionImpl; + private CursorPosition CursorPositionImpl = new(Vector2.Zero, Vector2.Zero, Vector2.Zero, Vector2.Zero); /// <summary>The player's last known tile position.</summary> private Vector2? LastPlayerTile; /// <summary>The buttons to press until the game next handles input.</summary> - private readonly HashSet<SButton> CustomPressedKeys = new HashSet<SButton>(); + private readonly HashSet<SButton> CustomPressedKeys = new(); /// <summary>The buttons to consider released until the actual button is released.</summary> - private readonly HashSet<SButton> CustomReleasedKeys = new HashSet<SButton>(); + private readonly HashSet<SButton> CustomReleasedKeys = new(); /// <summary>Whether there are new overrides in <see cref="CustomPressedKeys"/> or <see cref="CustomReleasedKeys"/> that haven't been applied to the previous state.</summary> private bool HasNewOverrides; @@ -72,8 +72,8 @@ namespace StardewModdingAPI.Framework.Input var controller = new GamePadStateBuilder(base.GetGamePadState()); var keyboard = new KeyboardStateBuilder(base.GetKeyboardState()); var mouse = new MouseStateBuilder(base.GetMouseState()); - Vector2 cursorAbsolutePos = new Vector2((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y); - Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null; + Vector2 cursorAbsolutePos = new((mouse.X * zoomMultiplier) + Game1.viewport.X, (mouse.Y * zoomMultiplier) + Game1.viewport.Y); + Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : null; HashSet<SButton> reallyDown = new HashSet<SButton>(this.GetPressedButtons(keyboard, mouse, controller)); // apply overrides @@ -104,7 +104,7 @@ namespace StardewModdingAPI.Framework.Input this.KeyboardState = keyboard.GetState(); this.MouseState = mouse.GetState(); this.ButtonStates = activeButtons; - if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile) + if (cursorAbsolutePos != this.CursorPositionImpl.AbsolutePixels || playerTilePos != this.LastPlayerTile) { this.LastPlayerTile = playerTilePos; this.CursorPositionImpl = this.GetCursorPosition(this.MouseState, cursorAbsolutePos, zoomMultiplier); @@ -203,8 +203,8 @@ namespace StardewModdingAPI.Framework.Input /// <param name="zoomMultiplier">The multiplier applied to pixel coordinates to adjust them for pixel zoom.</param> private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels, float zoomMultiplier) { - Vector2 screenPixels = new Vector2(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier); - Vector2 tile = new Vector2((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize)); + Vector2 screenPixels = new(mouseState.X * zoomMultiplier, mouseState.Y * zoomMultiplier); + Vector2 tile = new((int)((Game1.viewport.X + screenPixels.X) / Game1.tileSize), (int)((Game1.viewport.Y + screenPixels.Y) / Game1.tileSize)); Vector2 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton ? tile : Game1.player.GetGrabTile(); @@ -234,7 +234,7 @@ namespace StardewModdingAPI.Framework.Input isDown: pressed.Contains(button) ); - if (button == SButton.MouseLeft || button == SButton.MouseMiddle || button == SButton.MouseRight || button == SButton.MouseX1 || button == SButton.MouseX2) + if (button is SButton.MouseLeft or SButton.MouseMiddle or SButton.MouseRight or SButton.MouseX1 or SButton.MouseX2) mouseOverrides[button] = newState; else if (button.TryGetKeyboard(out Keys _)) keyboardOverrides[button] = newState; diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index 4cb77a45..580651f3 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Threading; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Events; @@ -42,9 +41,21 @@ namespace StardewModdingAPI.Framework /// <param name="level">The log severity level.</param> public static void LogAsMod(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace) { + if (metadata.Monitor is null) + throw new InvalidOperationException($"Can't log as mod {metadata.DisplayName}: mod is broken or a content pack. Logged message:\n[{level}] {message}"); + metadata.Monitor.Log(message, level); } + /// <summary>Log a message using the mod's monitor, but only if it hasn't already been logged since the last game launch.</summary> + /// <param name="metadata">The mod whose monitor to use.</param> + /// <param name="message">The message to log.</param> + /// <param name="level">The log severity level.</param> + public static void LogAsModOnce(this IModMetadata metadata, string message, LogLevel level = LogLevel.Trace) + { + metadata.Monitor?.LogOnce(message, level); + } + /**** ** ManagedEvent ****/ diff --git a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs index bad69a2a..9ecc1626 100644 --- a/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs +++ b/src/SMAPI/Framework/Logging/InterceptingTextWriter.cs @@ -8,6 +8,13 @@ namespace StardewModdingAPI.Framework.Logging internal class InterceptingTextWriter : TextWriter { /********* + ** Fields + *********/ + /// <summary>The event raised when a message is written to the console directly.</summary> + private readonly Action<string> OnMessageIntercepted; + + + /********* ** Accessors *********/ /// <summary>Prefixing a message with this character indicates that the console interceptor should write the string without intercepting it. (The character itself is not written.)</summary> @@ -19,9 +26,6 @@ namespace StardewModdingAPI.Framework.Logging /// <inheritdoc /> public override Encoding Encoding => this.Out.Encoding; - /// <summary>The event raised when a message is written to the console directly.</summary> - public event Action<string> OnMessageIntercepted; - /// <summary>Whether the text writer should ignore the next input if it's a newline.</summary> /// <remarks>This is used when log output is suppressed from the console, since <c>Console.WriteLine</c> writes the trailing newline as a separate call.</remarks> public bool IgnoreNextIfNewline { get; set; } @@ -32,9 +36,11 @@ namespace StardewModdingAPI.Framework.Logging *********/ /// <summary>Construct an instance.</summary> /// <param name="output">The underlying output writer.</param> - public InterceptingTextWriter(TextWriter output) + /// <param name="onMessageIntercepted">The event raised when a message is written to the console directly.</param> + public InterceptingTextWriter(TextWriter output, Action<string> onMessageIntercepted) { this.Out = output; + this.OnMessageIntercepted = onMessageIntercepted; } /// <inheritdoc /> @@ -63,7 +69,7 @@ namespace StardewModdingAPI.Framework.Logging this.Out.Write(buffer, index, count); } else - this.OnMessageIntercepted?.Invoke(new string(buffer, index, count)); + this.OnMessageIntercepted(new string(buffer, index, count)); } /// <inheritdoc /> @@ -72,12 +78,6 @@ namespace StardewModdingAPI.Framework.Logging this.Out.Write(ch); } - /// <inheritdoc /> - protected override void Dispose(bool disposing) - { - this.OnMessageIntercepted = null; - } - /********* ** Private methods diff --git a/src/SMAPI/Framework/Logging/LogFileManager.cs b/src/SMAPI/Framework/Logging/LogFileManager.cs index 6ab2bdfb..b396091a 100644 --- a/src/SMAPI/Framework/Logging/LogFileManager.cs +++ b/src/SMAPI/Framework/Logging/LogFileManager.cs @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.Logging this.Path = path; // create log directory if needed - string logDir = System.IO.Path.GetDirectoryName(path); + string? logDir = System.IO.Path.GetDirectoryName(path); if (logDir == null) throw new ArgumentException($"The log path '{path}' is not valid."); Directory.CreateDirectory(logDir); diff --git a/src/SMAPI/Framework/Logging/LogManager.cs b/src/SMAPI/Framework/Logging/LogManager.cs index a8a8b6ee..b94807b5 100644 --- a/src/SMAPI/Framework/Logging/LogManager.cs +++ b/src/SMAPI/Framework/Logging/LogManager.cs @@ -93,23 +93,26 @@ namespace StardewModdingAPI.Framework.Logging /// <param name="getScreenIdForLog">Get the screen ID that should be logged to distinguish between players in split-screen mode, if any.</param> public LogManager(string logPath, ColorSchemeConfig colorConfig, bool writeToConsole, bool isVerbose, bool isDeveloperMode, Func<int?> getScreenIdForLog) { - // init construction logic + // init log file + this.LogFile = new LogFileManager(logPath); + + // init monitor this.GetMonitorImpl = name => new Monitor(name, LogManager.IgnoreChar, this.LogFile, colorConfig, isVerbose, getScreenIdForLog) { WriteToConsole = writeToConsole, ShowTraceInConsole = isDeveloperMode, ShowFullStampInConsole = isDeveloperMode }; - - // init fields - this.LogFile = new LogFileManager(logPath); this.Monitor = this.GetMonitor("SMAPI"); this.MonitorForGame = this.GetMonitor("game"); // redirect direct console output - this.ConsoleInterceptor = new InterceptingTextWriter(Console.Out); - if (writeToConsole) - this.ConsoleInterceptor.OnMessageIntercepted += message => this.HandleConsoleMessage(this.MonitorForGame, message); + this.ConsoleInterceptor = new InterceptingTextWriter( + output: Console.Out, + onMessageIntercepted: writeToConsole + ? message => this.HandleConsoleMessage(this.MonitorForGame, message) + : _ => { } + ); Console.SetOut(this.ConsoleInterceptor); // enable Unicode handling on Windows @@ -154,7 +157,7 @@ namespace StardewModdingAPI.Framework.Logging while (true) { // get input - string input = Console.ReadLine(); + string? input = Console.ReadLine(); if (string.IsNullOrWhiteSpace(input)) continue; @@ -220,7 +223,7 @@ namespace StardewModdingAPI.Framework.Logging if (File.Exists(Constants.UpdateMarker)) { string[] rawUpdateFound = File.ReadAllText(Constants.UpdateMarker).Split(new[] { '|' }, 2); - if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion updateFound)) + if (SemanticVersion.TryParse(rawUpdateFound[0], out ISemanticVersion? updateFound)) { if (Constants.ApiVersion.IsPrerelease() && updateFound.IsNewerThan(Constants.ApiVersion)) { @@ -262,7 +265,7 @@ namespace StardewModdingAPI.Framework.Logging /// <summary>Log the initial header with general SMAPI and system details.</summary> /// <param name="modsPath">The path from which mods will be loaded.</param> /// <param name="customSettings">The custom SMAPI settings.</param> - public void LogIntro(string modsPath, IDictionary<string, object> customSettings) + public void LogIntro(string modsPath, IDictionary<string, object?> customSettings) { // log platform this.Monitor.Log($"SMAPI {Constants.ApiVersion} with Stardew Valley {Constants.GameVersion} (build {Constants.GetBuildVersionLabel()}) on {EnvironmentUtility.GetFriendlyPlatformName(Constants.Platform)}", LogLevel.Info); @@ -324,7 +327,7 @@ namespace StardewModdingAPI.Framework.Logging // log loaded content packs if (loadedContentPacks.Any()) { - string GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; + string? GetModDisplayName(string id) => loadedMods.FirstOrDefault(p => p.HasID(id))?.DisplayName; this.Monitor.Log($"Loaded {loadedContentPacks.Length} content packs:", LogLevel.Info); foreach (IModMetadata metadata in loadedContentPacks.OrderBy(p => p.DisplayName)) @@ -333,7 +336,7 @@ namespace StardewModdingAPI.Framework.Logging this.Monitor.Log( $" {metadata.DisplayName} {manifest.Version}" + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") - + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" + + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor!.UniqueID)}" + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), LogLevel.Info ); @@ -396,6 +399,7 @@ namespace StardewModdingAPI.Framework.Logging /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which could not be loaded.</param> /// <param name="logParanoidWarnings">Whether to log issues for mods which directly use potentially sensitive .NET APIs like file or shell access.</param> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifests aren't guaranteed non-null at this point in the loading process.")] private void LogModWarnings(IEnumerable<IModMetadata> mods, IModMetadata[] skippedMods, bool logParanoidWarnings) { // get mods with warnings @@ -429,7 +433,7 @@ namespace StardewModdingAPI.Framework.Logging // duplicate mod: log first one only, don't show redundant version if (mod.FailReason == ModFailReason.Duplicate && mod.HasManifest()) { - if (loggedDuplicateIds.Add(mod.Manifest.UniqueID)) + if (loggedDuplicateIds.Add(mod.Manifest!.UniqueID)) continue; // already logged message = $" - {mod.DisplayName} because {mod.Error}"; @@ -608,7 +612,7 @@ namespace StardewModdingAPI.Framework.Logging /// <param name="heading">A brief heading label for the group.</param> /// <param name="blurb">A detailed explanation of the warning, split into lines.</param> /// <param name="modLabel">Formats the mod label, or <c>null</c> to use the <see cref="IModMetadata.DisplayName"/>.</param> - private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string> modLabel = null) + private void LogModWarningGroup(IModMetadata[] mods, Func<IModMetadata, bool> match, LogLevel level, string heading, string[] blurb, Func<IModMetadata, string>? modLabel = null) { // get matching mods string[] modLabels = mods diff --git a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs index 5a3d4bed..12390976 100644 --- a/src/SMAPI/Framework/ModHelpers/BaseHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/BaseHelper.cs @@ -4,20 +4,27 @@ namespace StardewModdingAPI.Framework.ModHelpers internal abstract class BaseHelper : IModLinked { /********* + ** Fields + *********/ + /// <summary>The mod using this instance.</summary> + protected readonly IModMetadata Mod; + + + /********* ** Accessors *********/ /// <inheritdoc /> - public string ModID { get; } + public string ModID => this.Mod.Manifest.UniqueID; /********* ** Protected methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> - protected BaseHelper(string modID) + /// <param name="mod">The mod using this instance.</param> + protected BaseHelper(IModMetadata mod) { - this.ModID = modID; + this.Mod = mod; } } } diff --git a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs index 69382009..226a8d69 100644 --- a/src/SMAPI/Framework/ModHelpers/CommandHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/CommandHelper.cs @@ -1,4 +1,5 @@ using System; +using StardewModdingAPI.Framework.Deprecations; namespace StardewModdingAPI.Framework.ModHelpers { @@ -8,9 +9,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Fields *********/ - /// <summary>The mod using this instance.</summary> - private readonly IModMetadata Mod; - /// <summary>Manages console commands.</summary> private readonly CommandManager CommandManager; @@ -22,9 +20,8 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="mod">The mod using this instance.</param> /// <param name="commandManager">Manages console commands.</param> public CommandHelper(IModMetadata mod, CommandManager commandManager) - : base(mod?.Manifest?.UniqueID ?? "SMAPI") + : base(mod) { - this.Mod = mod; this.CommandManager = commandManager; } @@ -40,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers public bool Trigger(string name, string[] arguments) { SCore.DeprecationManager.Warn( - source: SCore.DeprecationManager.GetSourceName(this.ModID), + source: SCore.DeprecationManager.GetMod(this.ModID), nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.ConsoleCommands)}.{nameof(ICommandHelper.Trigger)}", version: "3.8.1", severity: DeprecationLevel.Notice diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index bfca2264..3c2441e8 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -7,12 +7,15 @@ using System.IO; using System.Linq; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; +using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers { /// <summary>Provides an API for loading content assets.</summary> + [Obsolete] internal class ContentHelper : BaseHelper, IContentHelper { /********* @@ -27,12 +30,12 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>A content manager for this mod which manages files from the mod's folder.</summary> private readonly ModContentManager ModContentManager; - /// <summary>The friendly mod name for use in errors.</summary> - private readonly string ModName; - /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + /********* ** Accessors @@ -44,16 +47,42 @@ namespace StardewModdingAPI.Framework.ModHelpers public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language; /// <summary>The observable implementation of <see cref="AssetEditors"/>.</summary> - internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new ObservableCollection<IAssetEditor>(); + internal ObservableCollection<IAssetEditor> ObservableAssetEditors { get; } = new(); /// <summary>The observable implementation of <see cref="AssetLoaders"/>.</summary> - internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new ObservableCollection<IAssetLoader>(); + internal ObservableCollection<IAssetLoader> ObservableAssetLoaders { get; } = new(); /// <inheritdoc /> - public IList<IAssetLoader> AssetLoaders => this.ObservableAssetLoaders; + public IList<IAssetLoader> AssetLoaders + { + get + { + SCore.DeprecationManager.Warn( + source: this.Mod, + nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetLoaders)}", + version: "3.14.0", + severity: DeprecationLevel.Notice + ); + + return this.ObservableAssetLoaders; + } + } /// <inheritdoc /> - public IList<IAssetEditor> AssetEditors => this.ObservableAssetEditors; + public IList<IAssetEditor> AssetEditors + { + get + { + SCore.DeprecationManager.Warn( + source: this.Mod, + nounPhrase: $"{nameof(IContentHelper)}.{nameof(IContentHelper.AssetEditors)}", + version: "3.14.0", + severity: DeprecationLevel.Notice + ); + + return this.ObservableAssetEditors; + } + } /********* @@ -62,48 +91,62 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Construct an instance.</summary> /// <param name="contentCore">SMAPI's core content logic.</param> /// <param name="modFolderPath">The absolute path to the mod folder.</param> - /// <param name="modID">The unique ID of the relevant mod.</param> - /// <param name="modName">The friendly mod name for use in errors.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> - public ContentHelper(ContentCoordinator contentCore, string modFolderPath, string modID, string modName, IMonitor monitor) - : base(modID) + /// <param name="reflection">Simplifies access to private code.</param> + public ContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, IMonitor monitor, Reflector reflection) + : base(mod) { - string managedAssetPrefix = contentCore.GetManagedAssetPrefix(modID); + string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID); this.ContentCore = contentCore; this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, this.GameContentManager); - this.ModName = modName; + this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, this.Mod.DisplayName, modFolderPath, this.GameContentManager); this.Monitor = monitor; + this.Reflection = reflection; } /// <inheritdoc /> public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) + where T : notnull { + IAssetName assetName = this.ContentCore.ParseAssetName(key, allowLocales: source == ContentSource.GameContent); + try { this.AssertAndNormalizeAssetName(key); switch (source) { case ContentSource.GameContent: - return this.GameContentManager.Load<T>(key, this.CurrentLocaleConstant, useCache: false); + if (assetName.Name.EndsWith(".xnb", StringComparison.OrdinalIgnoreCase)) + { + assetName = this.ContentCore.ParseAssetName(assetName.Name[..^4], allowLocales: true); + SCore.DeprecationManager.Warn( + this.Mod, + "loading assets from the Content folder with a .xnb file extension", + "3.14.0", + DeprecationLevel.Notice + ); + } + + return this.GameContentManager.LoadLocalized<T>(assetName, this.CurrentLocaleConstant, useCache: false); case ContentSource.ModFolder: - return this.ModContentManager.Load<T>(key, Constants.DefaultLanguage, useCache: false); + return this.ModContentManager.LoadExact<T>(assetName, useCache: false); default: - throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); + throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); } } - catch (Exception ex) when (!(ex is SContentLoadException)) + catch (Exception ex) when (ex is not SContentLoadException) { - throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}.", ex); + throw new SContentLoadException($"{this.Mod.DisplayName} failed loading content asset '{key}' from {source}.", ex); } } /// <inheritdoc /> [Pure] - public string NormalizeAssetName(string assetName) + public string NormalizeAssetName(string? assetName) { return this.ModContentManager.AssertAndNormalizeAssetName(assetName); } @@ -117,7 +160,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.GameContentManager.AssertAndNormalizeAssetName(key); case ContentSource.ModFolder: - return this.ModContentManager.GetInternalAssetKey(key); + return this.ModContentManager.GetInternalAssetKey(key).Name; default: throw new NotSupportedException($"Unknown content source '{source}'."); @@ -128,32 +171,41 @@ namespace StardewModdingAPI.Framework.ModHelpers public bool InvalidateCache(string key) { string actualKey = this.GetActualAssetKey(key, ContentSource.GameContent); - this.Monitor.Log($"Requested cache invalidation for '{actualKey}'.", LogLevel.Trace); - return this.ContentCore.InvalidateCache(asset => asset.AssetNameEquals(actualKey)).Any(); + this.Monitor.Log($"Requested cache invalidation for '{actualKey}'."); + return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(actualKey)).Any(); } /// <inheritdoc /> public bool InvalidateCache<T>() + where T : notnull { - this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible.", LogLevel.Trace); - return this.ContentCore.InvalidateCache((contentManager, key, type) => typeof(T).IsAssignableFrom(type)).Any(); + this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible."); + return this.ContentCore.InvalidateCache((_, _, type) => typeof(T).IsAssignableFrom(type)).Any(); } /// <inheritdoc /> public bool InvalidateCache(Func<IAssetInfo, bool> predicate) { - this.Monitor.Log("Requested cache invalidation for all assets matching a predicate.", LogLevel.Trace); + this.Monitor.Log("Requested cache invalidation for all assets matching a predicate."); return this.ContentCore.InvalidateCache(predicate).Any(); } /// <inheritdoc /> - public IAssetData GetPatchHelper<T>(T data, string assetName = null) + public IAssetData GetPatchHelper<T>(T data, string? assetName = null) + where T : notnull { if (data == null) throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); assetName ??= $"temp/{Guid.NewGuid():N}"; - return new AssetDataForObject(this.CurrentLocale, assetName, data, this.NormalizeAssetName); + + return new AssetDataForObject( + locale: this.CurrentLocale, + assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true/* no way to know if it's a game or mod asset here*/), + data: data, + getNormalizedPath: this.NormalizeAssetName, + reflection: this.Reflection + ); } diff --git a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs index d39abc7d..9f4a7ceb 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs @@ -22,11 +22,11 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="contentPacks">The content packs loaded for this mod.</param> /// <param name="createContentPack">Create a temporary content pack.</param> - public ContentPackHelper(string modID, Lazy<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack) - : base(modID) + public ContentPackHelper(IModMetadata mod, Lazy<IContentPack[]> contentPacks, Func<string, IManifest, IContentPack> createContentPack) + : base(mod) { this.ContentPacks = contentPacks; this.CreateContentPack = createContentPack; diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 4cbfd73f..2eaa940a 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -26,11 +26,11 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="modFolderPath">The absolute path to the mod folder.</param> /// <param name="jsonHelper">The absolute path to the mod folder.</param> - public DataHelper(string modID, string modFolderPath, JsonHelper jsonHelper) - : base(modID) + public DataHelper(IModMetadata mod, string modFolderPath, JsonHelper jsonHelper) + : base(mod) { this.ModFolderPath = modFolderPath; this.JsonHelper = jsonHelper; @@ -40,19 +40,21 @@ namespace StardewModdingAPI.Framework.ModHelpers ** JSON file ****/ /// <inheritdoc /> - public TModel ReadJsonFile<TModel>(string path) where TModel : class + public TModel? ReadJsonFile<TModel>(string path) + where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePath(path)); - return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data) ? data : null; } /// <inheritdoc /> - public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class + public void WriteJsonFile<TModel>(string path, TModel? data) + where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); @@ -69,7 +71,8 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Save file ****/ /// <inheritdoc /> - public TModel ReadSaveData<TModel>(string key) where TModel : class + public TModel? ReadSaveData<TModel>(string key) + where TModel : class { if (Context.LoadStage == LoadStage.None) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded."); @@ -80,14 +83,15 @@ namespace StardewModdingAPI.Framework.ModHelpers string internalKey = this.GetSaveFileKey(key); foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage)) { - if (dataField.TryGetValue(internalKey, out string value)) + if (dataField.TryGetValue(internalKey, out string? value)) return this.JsonHelper.Deserialize<TModel>(value); } return null; } /// <inheritdoc /> - public void WriteSaveData<TModel>(string key, TModel model) where TModel : class + public void WriteSaveData<TModel>(string key, TModel? model) + where TModel : class { if (Context.LoadStage == LoadStage.None) throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded."); @@ -95,7 +99,7 @@ namespace StardewModdingAPI.Framework.ModHelpers throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when connected to a remote host. (Save files are stored on the main player's computer.)"); string internalKey = this.GetSaveFileKey(key); - string data = model != null + string? data = model != null ? this.JsonHelper.Serialize(model, Formatting.None) : null; @@ -112,16 +116,18 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Global app data ****/ /// <inheritdoc /> - public TModel ReadGlobalData<TModel>(string key) where TModel : class + public TModel? ReadGlobalData<TModel>(string key) + where TModel : class { string path = this.GetGlobalDataPath(key); - return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) + return this.JsonHelper.ReadJsonFileIfExists(path, out TModel? data) ? data : null; } /// <inheritdoc /> - public void WriteGlobalData<TModel>(string key, TModel data) where TModel : class + public void WriteGlobalData<TModel>(string key, TModel? data) + where TModel : class { string path = this.GetGlobalDataPath(key); if (data != null) diff --git a/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs new file mode 100644 index 00000000..232e9287 --- /dev/null +++ b/src/SMAPI/Framework/ModHelpers/GameContentHelper.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.ContentManagers; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <inheritdoc cref="IGameContentHelper"/> + internal class GameContentHelper : BaseHelper, IGameContentHelper + { + /********* + ** Fields + *********/ + /// <summary>SMAPI's core content logic.</summary> + private readonly ContentCoordinator ContentCore; + + /// <summary>The underlying game content manager.</summary> + private readonly IContentManager GameContentManager; + + /// <summary>The friendly mod name for use in errors.</summary> + private readonly string ModName; + + /// <summary>Encapsulates monitoring and logging.</summary> + private readonly IMonitor Monitor; + + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + + + /********* + ** Accessors + *********/ + /// <inheritdoc /> + public string CurrentLocale => this.GameContentManager.GetLocale(); + + /// <inheritdoc /> + public LocalizedContentManager.LanguageCode CurrentLocaleConstant => this.GameContentManager.Language; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentCore">SMAPI's core content logic.</param> + /// <param name="mod">The mod using this instance.</param> + /// <param name="modName">The friendly mod name for use in errors.</param> + /// <param name="monitor">Encapsulates monitoring and logging.</param> + /// <param name="reflection">Simplifies access to private code.</param> + public GameContentHelper(ContentCoordinator contentCore, IModMetadata mod, string modName, IMonitor monitor, Reflector reflection) + : base(mod) + { + string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID); + + this.ContentCore = contentCore; + this.GameContentManager = contentCore.CreateGameContentManager(managedAssetPrefix + ".content"); + this.ModName = modName; + this.Monitor = monitor; + this.Reflection = reflection; + } + + /// <inheritdoc /> + public IAssetName ParseAssetName(string rawName) + { + return this.ContentCore.ParseAssetName(rawName, allowLocales: true); + } + + /// <inheritdoc /> + public T Load<T>(string key) + where T : notnull + { + IAssetName assetName = this.ContentCore.ParseAssetName(key, allowLocales: true); + return this.Load<T>(assetName); + } + + /// <inheritdoc /> + public T Load<T>(IAssetName assetName) + where T : notnull + { + try + { + return this.GameContentManager.LoadLocalized<T>(assetName, this.CurrentLocaleConstant, useCache: true); + } + catch (Exception ex) when (ex is not SContentLoadException) + { + throw new SContentLoadException($"{this.ModName} failed loading content asset '{assetName}' from the game content.", ex); + } + } + + /// <inheritdoc /> + public bool InvalidateCache(string key) + { + IAssetName assetName = this.ParseAssetName(key); + return this.InvalidateCache(assetName); + } + + /// <inheritdoc /> + public bool InvalidateCache(IAssetName assetName) + { + this.Monitor.Log($"Requested cache invalidation for '{assetName}'."); + return this.ContentCore.InvalidateCache(asset => asset.Name.IsEquivalentTo(assetName)).Any(); + } + + /// <inheritdoc /> + public bool InvalidateCache<T>() + where T : notnull + { + this.Monitor.Log($"Requested cache invalidation for all assets of type {typeof(T)}. This is an expensive operation and should be avoided if possible."); + return this.ContentCore.InvalidateCache((_, _, type) => typeof(T).IsAssignableFrom(type)).Any(); + } + + /// <inheritdoc /> + public bool InvalidateCache(Func<IAssetInfo, bool> predicate) + { + this.Monitor.Log("Requested cache invalidation for all assets matching a predicate."); + return this.ContentCore.InvalidateCache(predicate).Any(); + } + + /// <inheritdoc /> + public IAssetData GetPatchHelper<T>(T data, string? assetName = null) + where T : notnull + { + if (data == null) + throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); + + assetName ??= $"temp/{Guid.NewGuid():N}"; + + return new AssetDataForObject( + locale: this.CurrentLocale, + assetName: this.ContentCore.ParseAssetName(assetName, allowLocales: true), + data: data, + getNormalizedPath: key => this.ParseAssetName(key).Name, + reflection: this.Reflection + ); + } + + /// <summary>Get the underlying game content manager.</summary> + internal IContentManager GetUnderlyingContentManager() + { + return this.GameContentManager; + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/InputHelper.cs b/src/SMAPI/Framework/ModHelpers/InputHelper.cs index 88caf4c3..6c158258 100644 --- a/src/SMAPI/Framework/ModHelpers/InputHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/InputHelper.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param> - public InputHelper(string modID, Func<SInputState> currentInputState) - : base(modID) + public InputHelper(IModMetadata mod, Func<SInputState> currentInputState) + : base(mod) { this.CurrentInputState = currentInputState; } diff --git a/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs new file mode 100644 index 00000000..def0b728 --- /dev/null +++ b/src/SMAPI/Framework/ModHelpers/ModContentHelper.cs @@ -0,0 +1,101 @@ +using System; +using Microsoft.Xna.Framework.Content; +using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.ContentManagers; +using StardewModdingAPI.Framework.Exceptions; +using StardewModdingAPI.Framework.Reflection; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Framework.ModHelpers +{ + /// <inheritdoc cref="IModContentHelper"/> + internal class ModContentHelper : BaseHelper, IModContentHelper + { + /********* + ** Fields + *********/ + /// <summary>SMAPI's core content logic.</summary> + private readonly ContentCoordinator ContentCore; + + /// <summary>A content manager for this mod which manages files from the mod's folder.</summary> + private readonly ModContentManager ModContentManager; + + /// <summary>The friendly mod name for use in errors.</summary> + private readonly string ModName; + + /// <summary>A case-insensitive lookup of relative paths within the <see cref="ContentManager.RootDirectory"/>.</summary> + private readonly CaseInsensitivePathLookup RelativePathCache; + + /// <summary>Simplifies access to private code.</summary> + private readonly Reflector Reflection; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="contentCore">SMAPI's core content logic.</param> + /// <param name="modFolderPath">The absolute path to the mod folder.</param> + /// <param name="mod">The mod using this instance.</param> + /// <param name="modName">The friendly mod name for use in errors.</param> + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> + /// <param name="relativePathCache">A case-insensitive lookup of relative paths within the <paramref name="relativePathCache"/>.</param> + /// <param name="reflection">Simplifies access to private code.</param> + public ModContentHelper(ContentCoordinator contentCore, string modFolderPath, IModMetadata mod, string modName, IContentManager gameContentManager, CaseInsensitivePathLookup relativePathCache, Reflector reflection) + : base(mod) + { + string managedAssetPrefix = contentCore.GetManagedAssetPrefix(mod.Manifest.UniqueID); + + this.ContentCore = contentCore; + this.ModContentManager = contentCore.CreateModContentManager(managedAssetPrefix, modName, modFolderPath, gameContentManager); + this.ModName = modName; + this.RelativePathCache = relativePathCache; + this.Reflection = reflection; + } + + /// <inheritdoc /> + public T Load<T>(string relativePath) + where T : notnull + { + relativePath = this.RelativePathCache.GetAssetName(relativePath); + + IAssetName assetName = this.ContentCore.ParseAssetName(relativePath, allowLocales: false); + + try + { + return this.ModContentManager.LoadExact<T>(assetName, useCache: false); + } + catch (Exception ex) when (ex is not SContentLoadException) + { + throw new SContentLoadException($"{this.ModName} failed loading content asset '{relativePath}' from its mod folder.", ex); + } + } + + /// <inheritdoc /> + public IAssetName GetInternalAssetName(string relativePath) + { + relativePath = this.RelativePathCache.GetAssetName(relativePath); + return this.ModContentManager.GetInternalAssetKey(relativePath); + } + + /// <inheritdoc /> + public IAssetData GetPatchHelper<T>(T data, string? relativePath = null) + where T : notnull + { + if (data == null) + throw new ArgumentNullException(nameof(data), "Can't get a patch helper for a null value."); + + relativePath = relativePath != null + ? this.RelativePathCache.GetAssetName(relativePath) + : $"temp/{Guid.NewGuid():N}"; + + return new AssetDataForObject( + locale: this.ContentCore.GetLocale(), + assetName: this.ContentCore.ParseAssetName(relativePath, allowLocales: false), + data: data, + getNormalizedPath: key => this.ContentCore.ParseAssetName(key, allowLocales: false).Name, + reflection: this.Reflection + ); + } + } +} diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 058bff83..a23a9beb 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,6 +1,7 @@ using System; using System.IO; using StardewModdingAPI.Events; +using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Input; namespace StardewModdingAPI.Framework.ModHelpers @@ -9,6 +10,14 @@ namespace StardewModdingAPI.Framework.ModHelpers internal class ModHelper : BaseHelper, IModHelper, IDisposable { /********* + ** Fields + *********/ + /// <summary>The backing field for <see cref="Content"/>.</summary> + [Obsolete] + private readonly ContentHelper ContentImpl; + + + /********* ** Accessors *********/ /// <inheritdoc /> @@ -18,7 +27,27 @@ namespace StardewModdingAPI.Framework.ModHelpers public IModEvents Events { get; } /// <inheritdoc /> - public IContentHelper Content { get; } + [Obsolete] + public IContentHelper Content + { + get + { + SCore.DeprecationManager.Warn( + source: SCore.DeprecationManager.GetMod(this.ModID), + nounPhrase: $"{nameof(IModHelper)}.{nameof(IModHelper.Content)}", + version: "3.14.0", + severity: DeprecationLevel.Notice + ); + + return this.ContentImpl; + } + } + + /// <inheritdoc /> + public IGameContentHelper GameContent { get; } + + /// <inheritdoc /> + public IModContentHelper ModContent { get; } /// <inheritdoc /> public IContentPackHelper ContentPacks { get; } @@ -49,11 +78,13 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The mod's unique ID.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="modDirectory">The full path to the mod's folder.</param> /// <param name="currentInputState">Manages the game's input state for the current player instance. That may not be the main player in split-screen mode.</param> /// <param name="events">Manages access to events raised by SMAPI.</param> /// <param name="contentHelper">An API for loading content assets.</param> + /// <param name="gameContentHelper">An API for loading content assets from the game's <c>Content</c> folder or via <see cref="IModEvents.Content"/>.</param> + /// <param name="modContentHelper">An API for loading content assets from your mod's files.</param> /// <param name="contentPackHelper">An API for managing content packs.</param> /// <param name="commandHelper">An API for managing console commands.</param> /// <param name="dataHelper">An API for reading and writing persistent mod data.</param> @@ -63,8 +94,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="translationHelper">An API for reading translations stored in the mod's <c>i18n</c> folder.</param> /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> - public ModHelper(string modID, string modDirectory, Func<SInputState> currentInputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) - : base(modID) + public ModHelper( + IModMetadata mod, string modDirectory, Func<SInputState> currentInputState, IModEvents events, +#pragma warning disable CS0612 // deprecated code + ContentHelper contentHelper, +#pragma warning restore CS0612 + IGameContentHelper gameContentHelper, IModContentHelper modContentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper + ) + : base(mod) { // validate directory if (string.IsNullOrWhiteSpace(modDirectory)) @@ -74,10 +111,14 @@ namespace StardewModdingAPI.Framework.ModHelpers // initialize this.DirectoryPath = modDirectory; - this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); +#pragma warning disable CS0612 // deprecated code + this.ContentImpl = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); +#pragma warning restore CS0612 + this.GameContent = gameContentHelper ?? throw new ArgumentNullException(nameof(gameContentHelper)); + this.ModContent = modContentHelper ?? throw new ArgumentNullException(nameof(modContentHelper)); this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper)); this.Data = dataHelper ?? throw new ArgumentNullException(nameof(dataHelper)); - this.Input = new InputHelper(modID, currentInputState); + this.Input = new InputHelper(mod, currentInputState); this.ModRegistry = modRegistry ?? throw new ArgumentNullException(nameof(modRegistry)); this.ConsoleCommands = commandHelper ?? throw new ArgumentNullException(nameof(commandHelper)); this.Reflection = reflectionHelper ?? throw new ArgumentNullException(nameof(reflectionHelper)); @@ -86,6 +127,13 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Events = events; } + /// <summary>Get the underlying instance for <see cref="IContentHelper"/>.</summary> + [Obsolete] + public ContentHelper GetLegacyContentHelper() + { + return this.ContentImpl; + } + /**** ** Mod config file ****/ diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index ef1ad30c..348ba225 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -16,22 +16,22 @@ namespace StardewModdingAPI.Framework.ModHelpers private readonly IMonitor Monitor; /// <summary>The mod IDs for APIs accessed by this instanced.</summary> - private readonly HashSet<string> AccessedModApis = new HashSet<string>(); + private readonly HashSet<string> AccessedModApis = new(); /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> - private readonly InterfaceProxyFactory ProxyFactory; + private readonly IInterfaceProxyFactory ProxyFactory; /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="registry">The underlying mod registry.</param> /// <param name="proxyFactory">Generates proxy classes to access mod APIs through an arbitrary interface.</param> /// <param name="monitor">Encapsulates monitoring and logging for the mod.</param> - public ModRegistryHelper(string modID, ModRegistry registry, InterfaceProxyFactory proxyFactory, IMonitor monitor) - : base(modID) + public ModRegistryHelper(IModMetadata mod, ModRegistry registry, IInterfaceProxyFactory proxyFactory, IMonitor monitor) + : base(mod) { this.Registry = registry; this.ProxyFactory = proxyFactory; @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> - public IModInfo Get(string uniqueID) + public IModInfo? Get(string uniqueID) { return this.Registry.Get(uniqueID); } @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> - public object GetApi(string uniqueID) + public object? GetApi(string uniqueID) { // validate ready if (!this.Registry.AreAllModsInitialized) @@ -67,17 +67,18 @@ namespace StardewModdingAPI.Framework.ModHelpers } // get raw API - IModMetadata mod = this.Registry.Get(uniqueID); + IModMetadata? mod = this.Registry.Get(uniqueID); if (mod?.Api != null && this.AccessedModApis.Add(mod.Manifest.UniqueID)) - this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}.", LogLevel.Trace); + this.Monitor.Log($"Accessed mod-provided API for {mod.DisplayName}."); return mod?.Api; } /// <inheritdoc /> - public TInterface GetApi<TInterface>(string uniqueID) where TInterface : class + public TInterface? GetApi<TInterface>(string uniqueID) + where TInterface : class { // get raw API - object api = this.GetApi(uniqueID); + object? api = this.GetApi(uniqueID); if (api == null) return null; @@ -94,9 +95,9 @@ namespace StardewModdingAPI.Framework.ModHelpers } // get API of type - if (api is TInterface castApi) - return castApi; - return this.ProxyFactory.CreateProxy<TInterface>(api, this.ModID, uniqueID); + return api is TInterface castApi + ? castApi + : this.ProxyFactory.CreateProxy<TInterface>(api, sourceModID: this.ModID, targetModID: uniqueID); } } } diff --git a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs index a7ce8692..6900a1d2 100644 --- a/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/MultiplayerHelper.cs @@ -18,10 +18,10 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="multiplayer">SMAPI's core multiplayer utility.</param> - public MultiplayerHelper(string modID, SMultiplayer multiplayer) - : base(modID) + public MultiplayerHelper(IModMetadata mod, SMultiplayer multiplayer) + : base(mod) { this.Multiplayer = multiplayer; } @@ -39,9 +39,9 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> - public IMultiplayerPeer GetConnectedPlayer(long id) + public IMultiplayerPeer? GetConnectedPlayer(long id) { - return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer peer) + return this.Multiplayer.Peers.TryGetValue(id, out MultiplayerPeer? peer) ? peer : null; } @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> - public void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null) + public void SendMessage<TMessage>(TMessage message, string messageType, string[]? modIDs = null, long[]? playerIDs = null) { this.Multiplayer.BroadcastModMessage( message: message, diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 5a4ea742..a559906b 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -22,11 +22,11 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="modName">The mod name for error messages.</param> /// <param name="reflector">The underlying reflection helper.</param> - public ReflectionHelper(string modID, string modName, Reflector reflector) - : base(modID) + public ReflectionHelper(IModMetadata mod, string modName, Reflector reflector) + : base(mod) { this.ModName = modName; this.Reflector = reflector; @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetField<TValue>(obj, name, required) - ); + )!; } /// <inheritdoc /> @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetField<TValue>(type, name, required) - ); + )!; } /// <inheritdoc /> @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetProperty<TValue>(obj, name, required) - ); + )!; } /// <inheritdoc /> @@ -61,7 +61,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetProperty<TValue>(type, name, required) - ); + )!; } /// <inheritdoc /> @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetMethod(obj, name, required) - ); + )!; } /// <inheritdoc /> @@ -77,7 +77,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { return this.AssertAccessAllowed( this.Reflector.GetMethod(type, name, required) - ); + )!; } @@ -88,7 +88,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <typeparam name="T">The field value type.</typeparam> /// <param name="field">The field being accessed.</param> /// <returns>Returns the same field instance for convenience.</returns> - private IReflectedField<T> AssertAccessAllowed<T>(IReflectedField<T> field) + private IReflectedField<T>? AssertAccessAllowed<T>(IReflectedField<T>? field) { this.AssertAccessAllowed(field?.FieldInfo); return field; @@ -98,7 +98,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <typeparam name="T">The property value type.</typeparam> /// <param name="property">The property being accessed.</param> /// <returns>Returns the same property instance for convenience.</returns> - private IReflectedProperty<T> AssertAccessAllowed<T>(IReflectedProperty<T> property) + private IReflectedProperty<T>? AssertAccessAllowed<T>(IReflectedProperty<T>? property) { this.AssertAccessAllowed(property?.PropertyInfo.GetMethod?.GetBaseDefinition()); this.AssertAccessAllowed(property?.PropertyInfo.SetMethod?.GetBaseDefinition()); @@ -108,7 +108,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Assert that mods can use the reflection helper to access the given member.</summary> /// <param name="method">The method being accessed.</param> /// <returns>Returns the same method instance for convenience.</returns> - private IReflectedMethod AssertAccessAllowed(IReflectedMethod method) + private IReflectedMethod? AssertAccessAllowed(IReflectedMethod? method) { this.AssertAccessAllowed(method?.MethodInfo.GetBaseDefinition()); return method; @@ -116,18 +116,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Assert that mods can use the reflection helper to access the given member.</summary> /// <param name="member">The member being accessed.</param> - private void AssertAccessAllowed(MemberInfo member) + private void AssertAccessAllowed(MemberInfo? member) { if (member == null) return; // get type which defines the member - Type declaringType = member.DeclaringType; + Type? declaringType = member.DeclaringType; if (declaringType == null) throw new InvalidOperationException($"Can't validate access to {member.MemberType} {member.Name} because it has no declaring type."); // should never happen // validate access - string rootNamespace = typeof(Program).Namespace; + string? rootNamespace = typeof(Program).Namespace; if (declaringType.Namespace == rootNamespace || declaringType.Namespace?.StartsWith(rootNamespace + ".") == true) throw new InvalidOperationException($"SMAPI blocked access by {this.ModName} to its internals through the reflection API. Accessing the SMAPI internals is strongly discouraged since they're subject to change, which means the mod can break without warning. (Detected access to {declaringType.FullName}.{member.Name}.)"); } diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs index 869664fe..ae49d651 100644 --- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs @@ -27,11 +27,11 @@ namespace StardewModdingAPI.Framework.ModHelpers ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="modID">The unique ID of the relevant mod.</param> + /// <param name="mod">The mod using this instance.</param> /// <param name="locale">The initial locale.</param> /// <param name="languageCode">The game's current language code.</param> - public TranslationHelper(string modID, string locale, LocalizedContentManager.LanguageCode languageCode) - : base(modID) + public TranslationHelper(IModMetadata mod, string locale, LocalizedContentManager.LanguageCode languageCode) + : base(mod) { this.Translator = new Translator(); this.Translator.SetLocale(locale, languageCode); @@ -50,7 +50,7 @@ namespace StardewModdingAPI.Framework.ModHelpers } /// <inheritdoc /> - public Translation Get(string key, object tokens) + public Translation Get(string key, object? tokens) { return this.Translator.Get(key, tokens); } @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this; } - /// <summary>Set the current locale and precache translations.</summary> + /// <summary>Set the current locale and pre-cache translations.</summary> /// <param name="locale">The current locale.</param> /// <param name="localeEnum">The game's current language code.</param> internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) diff --git a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs index 8e2f5ef3..b3378ad1 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyDefinitionResolver.cs @@ -36,6 +36,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Resolve an assembly reference.</summary> /// <param name="name">The assembly name.</param> + /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception> public override AssemblyDefinition Resolve(AssemblyNameReference name) { return this.ResolveName(name.Name) ?? base.Resolve(name); @@ -44,6 +45,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Resolve an assembly reference.</summary> /// <param name="name">The assembly name.</param> /// <param name="parameters">The assembly reader parameters.</param> + /// <exception cref="AssemblyResolutionException">The assembly can't be resolved.</exception> public override AssemblyDefinition Resolve(AssemblyNameReference name, ReaderParameters parameters) { return this.ResolveName(name.Name) ?? base.Resolve(name, parameters); @@ -55,9 +57,9 @@ namespace StardewModdingAPI.Framework.ModLoading *********/ /// <summary>Resolve a known assembly definition based on its short or full name.</summary> /// <param name="name">The assembly's short or full name.</param> - private AssemblyDefinition ResolveName(string name) + private AssemblyDefinition? ResolveName(string name) { - return this.Lookup.TryGetValue(name, out AssemblyDefinition match) + return this.Lookup.TryGetValue(name, out AssemblyDefinition? match) ? match : null; } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index cb5fa2ae..72b547b1 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -36,13 +36,13 @@ namespace StardewModdingAPI.Framework.ModLoading private readonly AssemblyDefinitionResolver AssemblyDefinitionResolver; /// <summary>Provides assembly symbol readers for Mono.Cecil.</summary> - private readonly SymbolReaderProvider SymbolReaderProvider = new SymbolReaderProvider(); + private readonly SymbolReaderProvider SymbolReaderProvider = new(); /// <summary>Provides assembly symbol writers for Mono.Cecil.</summary> - private readonly SymbolWriterProvider SymbolWriterProvider = new SymbolWriterProvider(); + private readonly SymbolWriterProvider SymbolWriterProvider = new(); /// <summary>The objects to dispose as part of this instance.</summary> - private readonly HashSet<IDisposable> Disposables = new HashSet<IDisposable>(); + private readonly HashSet<IDisposable> Disposables = new(); /// <summary>Whether to rewrite mods for compatibility.</summary> private readonly bool RewriteMods; @@ -94,7 +94,12 @@ namespace StardewModdingAPI.Framework.ModLoading // get referenced local assemblies AssemblyParseResult[] assemblies; { - HashSet<string> visitedAssemblyNames = new HashSet<string>(AppDomain.CurrentDomain.GetAssemblies().Select(p => p.GetName().Name)); // don't try loading assemblies that are already loaded + HashSet<string> visitedAssemblyNames = new HashSet<string>( // don't try loading assemblies that are already loaded + from assembly in AppDomain.CurrentDomain.GetAssemblies() + let name = assembly.GetName().Name + where name != null + select name + ); assemblies = this.GetReferencedLocalAssemblies(new FileInfo(assemblyPath), visitedAssemblyNames, this.AssemblyDefinitionResolver).ToArray(); } @@ -111,11 +116,11 @@ namespace StardewModdingAPI.Framework.ModLoading // rewrite & load assemblies in leaf-to-root order bool oneAssembly = assemblies.Length == 1; - Assembly lastAssembly = null; + Assembly? lastAssembly = null; HashSet<string> loggedMessages = new HashSet<string>(); foreach (AssemblyParseResult assembly in assemblies) { - if (assembly.Status == AssemblyLoadStatus.AlreadyLoaded) + if (!assembly.HasDefinition) continue; // rewrite assembly @@ -138,11 +143,11 @@ namespace StardewModdingAPI.Framework.ModLoading if (changed) { if (!oneAssembly) - this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)...", LogLevel.Trace); + this.Monitor.Log($" Loading {assembly.File.Name} (rewritten)..."); // load assembly - using MemoryStream outAssemblyStream = new MemoryStream(); - using MemoryStream outSymbolStream = new MemoryStream(); + using MemoryStream outAssemblyStream = new(); + using MemoryStream outSymbolStream = new(); assembly.Definition.Write(outAssemblyStream, new WriterParameters { WriteSymbols = true, SymbolStream = outSymbolStream, SymbolWriterProvider = this.SymbolWriterProvider }); byte[] bytes = outAssemblyStream.ToArray(); lastAssembly = Assembly.Load(bytes, outSymbolStream.ToArray()); @@ -150,7 +155,7 @@ namespace StardewModdingAPI.Framework.ModLoading else { if (!oneAssembly) - this.Monitor.Log($" Loading {assembly.File.Name}...", LogLevel.Trace); + this.Monitor.Log($" Loading {assembly.File.Name}..."); lastAssembly = Assembly.UnsafeLoadFrom(assembly.File.FullName); } @@ -163,7 +168,7 @@ namespace StardewModdingAPI.Framework.ModLoading throw new IncompatibleInstructionException(); // last assembly loaded is the root - return lastAssembly; + return lastAssembly!; } /// <summary>Get whether an assembly is loaded.</summary> @@ -172,7 +177,8 @@ namespace StardewModdingAPI.Framework.ModLoading { try { - return this.AssemblyDefinitionResolver.Resolve(reference) != null; + _ = this.AssemblyDefinitionResolver.Resolve(reference); + return true; } catch (AssemblyResolutionException) { @@ -188,7 +194,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// assemblies (especially with Mono). Since this is meant to be called on <see cref="AppDomain.AssemblyResolve"/>, /// the implicit assumption is that loading the exact assembly failed. /// </remarks> - public static Assembly ResolveAssembly(string name) + public static Assembly? ResolveAssembly(string name) { string shortName = name.Split(new[] { ',' }, 2).First(); // get simple name (without version and culture) return AppDomain.CurrentDomain @@ -210,7 +216,8 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Track an object for disposal as part of the assembly loader.</summary> /// <typeparam name="T">The instance type.</typeparam> /// <param name="instance">The disposable instance.</param> - private T TrackForDisposal<T>(T instance) where T : IDisposable + private T TrackForDisposal<T>(T instance) + where T : IDisposable { this.Disposables.Add(instance); return instance; @@ -241,7 +248,7 @@ namespace StardewModdingAPI.Framework.ModLoading try { // read assembly with symbols - FileInfo symbolsFile = new FileInfo(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb"); + FileInfo symbolsFile = new(Path.Combine(Path.GetDirectoryName(file.FullName)!, Path.GetFileNameWithoutExtension(file.FullName)) + ".pdb"); if (symbolsFile.Exists) this.SymbolReaderProvider.TryAddSymbolData(file.Name, () => this.TrackForDisposal(symbolsFile.OpenRead())); assembly = this.TrackForDisposal(AssemblyDefinition.ReadAssembly(readStream, new ReaderParameters(ReadingMode.Immediate) { AssemblyResolver = assemblyResolver, InMemory = true, ReadSymbols = true, SymbolReaderProvider = this.SymbolReaderProvider })); @@ -266,7 +273,7 @@ namespace StardewModdingAPI.Framework.ModLoading // yield referenced assemblies foreach (AssemblyNameReference dependency in assembly.MainModule.AssemblyReferences) { - FileInfo dependencyFile = new FileInfo(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); + FileInfo dependencyFile = new(Path.Combine(file.Directory.FullName, $"{dependency.Name}.dll")); foreach (AssemblyParseResult result in this.GetReferencedLocalAssemblies(dependencyFile, visitedAssemblyNames, assemblyResolver)) yield return result; } @@ -319,9 +326,9 @@ namespace StardewModdingAPI.Framework.ModLoading // rewrite types using custom attributes foreach (TypeDefinition type in module.GetTypes()) { - foreach (var attr in type.CustomAttributes) + foreach (CustomAttribute attr in type.CustomAttributes) { - foreach (var conField in attr.ConstructorArguments) + foreach (CustomAttributeArgument conField in attr.ConstructorArguments) { if (conField.Value is TypeReference typeRef) this.ChangeTypeScope(typeRef); @@ -333,7 +340,7 @@ namespace StardewModdingAPI.Framework.ModLoading // find or rewrite code IInstructionHandler[] handlers = new InstructionMetadata().GetHandlers(this.ParanoidMode, platformChanged, this.RewriteMods).ToArray(); - RecursiveRewriter rewriter = new RecursiveRewriter( + RecursiveRewriter rewriter = new( module: module, rewriteModule: curModule => { @@ -380,7 +387,7 @@ namespace StardewModdingAPI.Framework.ModLoading { // get message template // ($phrase is replaced with the noun phrase or messages) - string template = null; + string? template = null; switch (result) { case InstructionHandleResult.Rewritten: @@ -439,20 +446,20 @@ namespace StardewModdingAPI.Framework.ModLoading // format messages string phrase = handler.Phrases.Any() ? string.Join(", ", handler.Phrases) - : handler.DefaultPhrase ?? handler.GetType().Name; + : handler.DefaultPhrase; this.Monitor.LogOnce(loggedMessages, template.Replace("$phrase", phrase)); } /// <summary>Get the correct reference to use for compatibility with the current platform.</summary> /// <param name="type">The type reference to rewrite.</param> - private void ChangeTypeScope(TypeReference type) + private void ChangeTypeScope(TypeReference? type) { // check skip conditions if (type == null || type.FullName.StartsWith("System.")) return; // get assembly - if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly assembly)) + if (!this.TypeAssemblies.TryGetValue(type.FullName, out Assembly? assembly)) return; // replace scope diff --git a/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs b/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs index b56a776c..b133f8d6 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyParseResult.cs @@ -1,3 +1,5 @@ +using System; +using System.Diagnostics.CodeAnalysis; using System.IO; using Mono.Cecil; @@ -13,11 +15,15 @@ namespace StardewModdingAPI.Framework.ModLoading public readonly FileInfo File; /// <summary>The assembly definition.</summary> - public readonly AssemblyDefinition Definition; + public readonly AssemblyDefinition? Definition; /// <summary>The result of the assembly load.</summary> public AssemblyLoadStatus Status; + /// <summary>Whether the <see cref="Definition"/> is loaded and ready (i.e. the <see cref="Status"/> is not <see cref="AssemblyLoadStatus.AlreadyLoaded"/> or <see cref="AssemblyLoadStatus.Failed"/>).</summary> + [MemberNotNullWhen(true, nameof(AssemblyParseResult.Definition))] + public bool HasDefinition => this.Status == AssemblyLoadStatus.Okay; + /********* ** Public methods @@ -26,11 +32,14 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="file">The original assembly file.</param> /// <param name="assembly">The assembly definition.</param> /// <param name="status">The result of the assembly load.</param> - public AssemblyParseResult(FileInfo file, AssemblyDefinition assembly, AssemblyLoadStatus status) + public AssemblyParseResult(FileInfo file, AssemblyDefinition? assembly, AssemblyLoadStatus status) { this.File = file; this.Definition = assembly; this.Status = status; + + if (status == AssemblyLoadStatus.Okay && assembly == null) + throw new InvalidOperationException($"Invalid assembly parse result: load status {status} with a null assembly."); } } } diff --git a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs index 124951a5..f5d449c5 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/EventFinder.cs @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders { if (this.MethodNames.Any()) { - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName && this.MethodNames.Contains(methodRef.Name)) { string eventName = methodRef.Name.Split(new[] { '_' }, 2)[1]; diff --git a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs index 68415123..7fe4abec 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/FieldFinder.cs @@ -49,7 +49,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders { if (this.FieldNames.Any()) { - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null && fieldRef.DeclaringType.FullName == this.FullTypeName && this.FieldNames.Contains(fieldRef.Name)) { this.FieldNames.Remove(fieldRef.Name); diff --git a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs index d2340f01..e8fdc8c7 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/MethodFinder.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="instruction">The IL instruction.</param> protected bool IsMatch(Instruction instruction) { - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); return methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName diff --git a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs index 99344848..2af76f55 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/PropertyFinder.cs @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="instruction">The IL instruction.</param> protected bool IsMatch(Instruction instruction) { - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); return methodRef != null && methodRef.DeclaringType.FullName == this.FullTypeName diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index 8c1cae2b..f34542c3 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; @@ -32,11 +33,11 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType)) { // get target field - FieldDefinition targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); + FieldDefinition? targetField = fieldRef.DeclaringType.Resolve()?.Fields.FirstOrDefault(p => p.Name == fieldRef.Name); if (targetField == null) return false; @@ -49,16 +50,16 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders } // method reference - MethodReference methodReference = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodReference = RewriteHelper.AsMethodReference(instruction); 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(); + MethodDefinition[]? candidateMethods = methodReference.DeclaringType.Resolve()?.Methods.Where(found => found.Name == methodReference.Name).ToArray(); if (candidateMethods == null || !candidateMethods.Any()) return false; // compare return types - MethodDefinition methodDef = methodReference.Resolve(); + MethodDefinition? methodDef = methodReference.Resolve(); if (methodDef == null) return false; // validated by ReferenceToMissingMemberFinder @@ -78,7 +79,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders *********/ /// <summary>Whether references to the given type should be validated.</summary> /// <param name="type">The type reference.</param> - private bool ShouldValidate(TypeReference type) + private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) { return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); } diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs index d305daf4..fae7fb12 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMissingMemberFinder.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.ModLoading.Framework; @@ -31,10 +32,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null && this.ShouldValidate(fieldRef.DeclaringType)) { - FieldDefinition target = fieldRef.Resolve(); + FieldDefinition? target = fieldRef.Resolve(); if (target == null || target.HasConstant) { this.MarkFlag(InstructionHandleResult.NotCompatible, $"reference to {fieldRef.DeclaringType.FullName}.{fieldRef.Name} (no such field)"); @@ -43,10 +44,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders } // method reference - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (methodRef != null && this.ShouldValidate(methodRef.DeclaringType) && !this.IsUnsupported(methodRef)) { - MethodDefinition target = methodRef.Resolve(); + MethodDefinition? target = methodRef.Resolve(); if (target == null) { string phrase; @@ -71,7 +72,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders *********/ /// <summary>Whether references to the given type should be validated.</summary> /// <param name="type">The type reference.</param> - private bool ShouldValidate(TypeReference type) + private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) { return type != null && this.ValidateReferencesToAssemblies.Contains(type.Scope.Name); } diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs index 24ab2eca..17acbf9a 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeAssemblyFinder.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders private readonly InstructionHandleResult Result; /// <summary>Get whether a matched type should be ignored.</summary> - private readonly Func<TypeReference, bool> ShouldIgnore; + private readonly Func<TypeReference, bool>? ShouldIgnore; /********* @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="assemblyName">The full assembly name to which to find references.</param> /// <param name="result">The result to return for matching instructions.</param> /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> - public TypeAssemblyFinder(string assemblyName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null) + public TypeAssemblyFinder(string assemblyName, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null) : base(defaultPhrase: $"{assemblyName} assembly") { this.AssemblyName = assemblyName; diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs index 260a8df8..77762f41 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders private readonly InstructionHandleResult Result; /// <summary>Get whether a matched type should be ignored.</summary> - private readonly Func<TypeReference, bool> ShouldIgnore; + private readonly Func<TypeReference, bool>? ShouldIgnore; /********* @@ -28,7 +28,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="fullTypeNames">The full type names to match.</param> /// <param name="result">The result to return for matching instructions.</param> /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> - public TypeFinder(string[] fullTypeNames, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null) + public TypeFinder(string[] fullTypeNames, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null) : base(defaultPhrase: $"{string.Join(", ", fullTypeNames)} type{(fullTypeNames.Length != 1 ? "s" : "")}") // default phrase should never be used { this.FullTypeNames = new HashSet<string>(fullTypeNames); @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders /// <param name="fullTypeName">The full type name to match.</param> /// <param name="result">The result to return for matching instructions.</param> /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> - public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool> shouldIgnore = null) + public TypeFinder(string fullTypeName, InstructionHandleResult result, Func<TypeReference, bool>? shouldIgnore = null) : this(new[] { fullTypeName }, result, shouldIgnore) { } /// <inheritdoc /> diff --git a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs index d5d1b38e..865bf076 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/BaseInstructionHandler.cs @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <param name="flag">The result flag to set.</param> /// <param name="resultMessage">The result message to add.</param> /// <returns>Returns true for convenience.</returns> - protected bool MarkFlag(InstructionHandleResult flag, string resultMessage = null) + protected bool MarkFlag(InstructionHandleResult flag, string? resultMessage = null) { this.Flags.Add(flag); if (resultMessage != null) diff --git a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs index 4f14a579..55369602 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RecursiveRewriter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; @@ -8,6 +9,7 @@ using Mono.Collections.Generic; namespace StardewModdingAPI.Framework.ModLoading.Framework { /// <summary>Handles recursively rewriting loaded assembly code.</summary> + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "Rewrite callbacks are invoked immediately.")] internal class RecursiveRewriter { /********* @@ -75,7 +77,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework { changed |= this.RewriteModuleImpl(this.Module); - foreach (var type in types) + foreach (TypeDefinition type in types) changed |= this.RewriteTypeDefinition(type); } catch (Exception ex) @@ -127,9 +129,10 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework ILProcessor cil = method.Body.GetILProcessor(); Collection<Instruction> instructions = cil.Body.Instructions; bool addedInstructions = false; + // ReSharper disable once ForCanBeConvertedToForeach -- deliberate to allow changing the collection for (int i = 0; i < instructions.Count; i++) { - var instruction = instructions[i]; + Instruction instruction = instructions[i]; if (instruction.OpCode.Code == Code.Nop) continue; @@ -172,7 +175,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework bool rewritten = false; // field reference - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null) { rewritten |= this.RewriteTypeReference(fieldRef.DeclaringType, newType => fieldRef.DeclaringType = newType); @@ -180,7 +183,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework } // method reference - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (methodRef != null) this.RewriteMethodReference(methodRef); @@ -210,7 +213,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework }); rewritten |= this.RewriteTypeReference(methodRef.ReturnType, newType => methodRef.ReturnType = newType); - foreach (var parameter in methodRef.Parameters) + foreach (ParameterDefinition parameter in methodRef.Parameters) rewritten |= this.RewriteTypeReference(parameter.ParameterType, newType => parameter.ParameterType = newType); if (methodRef is GenericInstanceMethod genericRef) @@ -262,7 +265,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework bool curChanged = false; // attribute type - TypeReference newAttrType = null; + TypeReference? newAttrType = null; rewritten |= this.RewriteTypeReference(attribute.AttributeType, newType => { newAttrType = newType; @@ -287,9 +290,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework if (curChanged) { // get constructor - MethodDefinition constructor = (newAttrType ?? attribute.AttributeType) + MethodDefinition? constructor = (newAttrType ?? attribute.AttributeType) .Resolve() - .Methods + ?.Methods .Where(method => method.IsConstructor) .FirstOrDefault(ctor => RewriteHelper.HasMatchingSignature(ctor, attribute.Constructor)); if (constructor == null) @@ -299,9 +302,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework var newAttr = new CustomAttribute(this.Module.ImportReference(constructor)); for (int i = 0; i < argTypes.Length; i++) newAttr.ConstructorArguments.Add(new CustomAttributeArgument(argTypes[i], attribute.ConstructorArguments[i].Value)); - foreach (var prop in attribute.Properties) + foreach (CustomAttributeNamedArgument prop in attribute.Properties) newAttr.Properties.Add(new CustomAttributeNamedArgument(prop.Name, prop.Argument)); - foreach (var field in attribute.Fields) + foreach (CustomAttributeNamedArgument field in attribute.Fields) newAttr.Fields.Add(new CustomAttributeNamedArgument(field.Name, field.Argument)); // swap attribute diff --git a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs index d7cb2471..15f71251 100644 --- a/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs +++ b/src/SMAPI/Framework/ModLoading/Framework/RewriteHelper.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework *********/ /// <summary>Get the field reference from an instruction if it matches.</summary> /// <param name="instruction">The IL instruction.</param> - public static FieldReference AsFieldReference(Instruction instruction) + public static FieldReference? AsFieldReference(Instruction instruction) { return instruction.OpCode == OpCodes.Ldfld || instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Stfld || instruction.OpCode == OpCodes.Stsfld ? (FieldReference)instruction.Operand @@ -30,7 +30,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <summary>Get the method reference from an instruction if it matches.</summary> /// <param name="instruction">The IL instruction.</param> - public static MethodReference AsMethodReference(Instruction instruction) + public static MethodReference? AsMethodReference(Instruction instruction) { return instruction.OpCode == OpCodes.Call || instruction.OpCode == OpCodes.Callvirt || instruction.OpCode == OpCodes.Newobj ? (MethodReference)instruction.Operand @@ -40,7 +40,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <summary>Get the CIL instruction to load a value onto the stack.</summary> /// <param name="rawValue">The constant value to inject.</param> /// <returns>Returns the instruction, or <c>null</c> if the value type isn't supported.</returns> - public static Instruction GetLoadValueInstruction(object rawValue) + public static Instruction? GetLoadValueInstruction(object? rawValue) { return rawValue switch { @@ -149,7 +149,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Framework /// <param name="typeA">The type ID to compare.</param> /// <param name="typeB">The other type ID to compare.</param> /// <returns>true if the type IDs look like the same type, false if not.</returns> - public static bool LooksLikeSameType(TypeReference typeA, TypeReference typeB) + public static bool LooksLikeSameType(TypeReference? typeA, TypeReference? typeB) { return RewriteHelper.TypeDefinitionComparer.Equals(typeA, typeB); } diff --git a/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs b/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs index 075e237a..b53a9886 100644 --- a/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs +++ b/src/SMAPI/Framework/ModLoading/InvalidModStateException.cs @@ -8,7 +8,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Construct an instance.</summary> /// <param name="message">The error message.</param> /// <param name="ex">The underlying exception, if any.</param> - public InvalidModStateException(string message, Exception ex = null) + public InvalidModStateException(string message, Exception? ex = null) : base(message, ex) { } } } diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 9e6bc61f..fe54634b 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using StardewModdingAPI.Framework.ModHelpers; @@ -42,7 +43,7 @@ namespace StardewModdingAPI.Framework.ModLoading public IManifest Manifest { get; } /// <inheritdoc /> - public ModDataRecordVersionedFields DataRecord { get; } + public ModDataRecordVersionedFields? DataRecord { get; } /// <inheritdoc /> public ModMetadataStatus Status { get; private set; } @@ -54,33 +55,35 @@ namespace StardewModdingAPI.Framework.ModLoading public ModWarning Warnings => this.ActualWarnings & ~(this.DataRecord?.DataRecord.SuppressWarnings ?? ModWarning.None); /// <inheritdoc /> - public string Error { get; private set; } + public string? Error { get; private set; } /// <inheritdoc /> - public string ErrorDetails { get; private set; } + public string? ErrorDetails { get; private set; } /// <inheritdoc /> public bool IsIgnored { get; } /// <inheritdoc /> - public IMod Mod { get; private set; } + public IMod? Mod { get; private set; } /// <inheritdoc /> - public IContentPack ContentPack { get; private set; } + public IContentPack? ContentPack { get; private set; } /// <inheritdoc /> - public TranslationHelper Translations { get; private set; } + public TranslationHelper? Translations { get; private set; } /// <inheritdoc /> - public IMonitor Monitor { get; private set; } + public IMonitor? Monitor { get; private set; } /// <inheritdoc /> - public object Api { get; private set; } + public object? Api { get; private set; } /// <inheritdoc /> - public ModEntryModel UpdateCheckData { get; private set; } + public ModEntryModel? UpdateCheckData { get; private set; } /// <inheritdoc /> + [MemberNotNullWhen(true, nameof(ModMetadata.ContentPack))] + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The manifest may be null for broken mods while loading.")] public bool IsContentPack => this.Manifest?.ContentPackFor != null; /// <summary>The fake content packs created by this mod, if any.</summary> @@ -97,13 +100,13 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="manifest">The mod manifest.</param> /// <param name="dataRecord">Metadata about the mod from SMAPI's internal data (if any).</param> /// <param name="isIgnored">Whether the mod folder should be ignored. This should be <c>true</c> if it was found within a folder whose name starts with a dot.</param> - public ModMetadata(string displayName, string directoryPath, string rootPath, IManifest manifest, ModDataRecordVersionedFields dataRecord, bool isIgnored) + public ModMetadata(string displayName, string directoryPath, string rootPath, IManifest? manifest, ModDataRecordVersionedFields? dataRecord, bool isIgnored) { this.DisplayName = displayName; this.DirectoryPath = directoryPath; this.RootPath = rootPath; this.RelativeDirectoryPath = PathUtilities.GetRelativePath(this.RootPath, this.DirectoryPath); - this.Manifest = manifest; + this.Manifest = manifest!; // manifest may be null in low-level SMAPI code, but won't be null once it's received by mods via IModInfo this.DataRecord = dataRecord; this.IsIgnored = isIgnored; @@ -119,7 +122,7 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <inheritdoc /> - public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string error, string errorDetails = null) + public IModMetadata SetStatus(ModMetadataStatus status, ModFailReason reason, string? error, string? errorDetails = null) { this.Status = status; this.FailReason = reason; @@ -160,7 +163,7 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <inheritdoc /> - public IModMetadata SetApi(object api) + public IModMetadata SetApi(object? api) { this.Api = api; return this; @@ -174,6 +177,7 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <inheritdoc /> + [MemberNotNullWhen(true, nameof(IModInfo.Manifest))] public bool HasManifest() { return this.Manifest != null; @@ -188,7 +192,7 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <inheritdoc /> - public bool HasID(string id) + public bool HasID(string? id) { return this.HasID() @@ -243,7 +247,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <inheritdoc /> public string GetRelativePathWithRoot() { - string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; + string rootFolderName = Path.GetFileName(this.RootPath); return Path.Combine(rootFolderName, this.RelativeDirectoryPath); } @@ -252,7 +256,7 @@ namespace StardewModdingAPI.Framework.ModLoading { foreach (var reference in this.FakeContentPacks.ToArray()) { - if (!reference.TryGetTarget(out ContentPack pack)) + if (!reference.TryGetTarget(out ContentPack? pack)) { this.FakeContentPacks.Remove(reference); continue; diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 4b05d1e5..74e7cb32 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using StardewModdingAPI.Toolkit; @@ -26,17 +27,14 @@ namespace StardewModdingAPI.Framework.ModLoading { foreach (ModFolder folder in toolkit.GetModFolders(rootPath)) { - Manifest manifest = folder.Manifest; + Manifest? manifest = folder.Manifest; // parse internal data record (if any) - ModDataRecordVersionedFields dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); + ModDataRecordVersionedFields? dataRecord = modDatabase.Get(manifest?.UniqueID)?.GetVersionedFields(manifest); // apply defaults - if (manifest != null && dataRecord != null) - { - if (dataRecord.UpdateKey != null) - manifest.UpdateKeys = new[] { dataRecord.UpdateKey }; - } + if (manifest != null && dataRecord?.UpdateKey is not null) + manifest.OverrideUpdateKeys(dataRecord.UpdateKey); // build metadata bool shouldIgnore = folder.Type == ModType.Ignored; @@ -44,7 +42,7 @@ namespace StardewModdingAPI.Framework.ModLoading ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - var metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore); + IModMetadata metadata = new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore); if (shouldIgnore) metadata.SetStatus(status, ModFailReason.DisabledByDotConvention, "disabled by dot convention"); else @@ -58,7 +56,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="mods">The mod manifests to validate.</param> /// <param name="apiVersion">The current SMAPI version.</param> /// <param name="getUpdateUrl">Get an update URL for an update key (if valid).</param> - public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string> getUpdateUrl) + /// <param name="validateFilesExist">Whether to validate that files referenced in the manifest (like <see cref="IManifest.EntryDll"/>) exist on disk. This can be disabled to only validate the manifest itself.</param> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "Manifest values may be null before they're validated.")] + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "Manifest values may be null before they're validated.")] + public void ValidateManifests(IEnumerable<IModMetadata> mods, ISemanticVersion apiVersion, Func<string, string?> getUpdateUrl, bool validateFilesExist = true) { mods = mods.ToArray(); @@ -85,7 +86,7 @@ namespace StardewModdingAPI.Framework.ModLoading List<string> updateUrls = new List<string>(); foreach (UpdateKey key in mod.GetUpdateKeys(validOnly: true)) { - string url = getUpdateUrl(key.ToString()); + string? url = getUpdateUrl(key.ToString()); if (url != null) updateUrls.Add(url); } @@ -95,7 +96,7 @@ namespace StardewModdingAPI.Framework.ModLoading // build error string error = $"{reasonPhrase}. Please check for a "; - if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version.Equals(mod.DataRecord.StatusUpperVersion)) + if (mod.DataRecord.StatusUpperVersion == null || mod.Manifest.Version?.Equals(mod.DataRecord.StatusUpperVersion) == true) error += "newer version"; else error += $"version newer than {mod.DataRecord.StatusUpperVersion}"; @@ -134,25 +135,21 @@ namespace StardewModdingAPI.Framework.ModLoading if (hasDll) { // invalid filename format - if (mod.Manifest.EntryDll.Intersect(Path.GetInvalidFileNameChars()).Any()) + if (mod.Manifest.EntryDll!.Intersect(Path.GetInvalidFileNameChars()).Any()) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest has invalid filename '{mod.Manifest.EntryDll}' for the EntryDLL field."); continue; } - // invalid path - if (!File.Exists(Path.Combine(mod.DirectoryPath, mod.Manifest.EntryDll))) - { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); - continue; - } - - // invalid capitalization - string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name; - if (actualFilename != mod.Manifest.EntryDll) + // file doesn't exist + if (validateFilesExist) { - mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); - continue; + string fileName = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(mod.Manifest.EntryDll!); + if (!File.Exists(Path.Combine(mod.DirectoryPath, fileName))) + { + mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its DLL '{mod.Manifest.EntryDll}' doesn't exist."); + continue; + } } } @@ -160,7 +157,7 @@ namespace StardewModdingAPI.Framework.ModLoading else { // invalid content pack ID - if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor.UniqueID)) + if (string.IsNullOrWhiteSpace(mod.Manifest.ContentPackFor!.UniqueID)) { mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, $"its manifest declares {nameof(IManifest.ContentPackFor)} without its required {nameof(IManifestContentPackFor.UniqueID)} field."); continue; @@ -191,7 +188,7 @@ namespace StardewModdingAPI.Framework.ModLoading mod.SetStatus(ModMetadataStatus.Failed, ModFailReason.InvalidManifest, "its manifest specifies an invalid ID (IDs must only contain letters, numbers, underscores, periods, or hyphens)."); // validate dependencies - foreach (var dependency in mod.Manifest.Dependencies) + foreach (IManifestDependency? dependency in mod.Manifest.Dependencies) { // null dependency if (dependency == null) @@ -240,7 +237,7 @@ namespace StardewModdingAPI.Framework.ModLoading // initialize metadata mods = mods.ToArray(); var sortedMods = new Stack<IModMetadata>(); - var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); + var states = mods.ToDictionary(mod => mod, _ => ModDependencyStatus.Queued); // handle failed mods foreach (IModMetadata mod in mods.Where(m => m.Status == ModMetadataStatus.Failed)) @@ -329,8 +326,11 @@ namespace StardewModdingAPI.Framework.ModLoading string[] failedLabels = ( from entry in dependencies - where entry.Mod != null && entry.MinVersion != null && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) - select $"{entry.Mod.DisplayName} (needs {entry.MinVersion} or later)" + where + entry.Mod != null + && entry.MinVersion != null + && entry.MinVersion.IsNewerThan(entry.Mod.Manifest.Version) + select $"{entry.Mod!.DisplayName} (needs {entry.MinVersion} or later)" ) .ToArray(); if (failedLabels.Any()) @@ -346,16 +346,14 @@ namespace StardewModdingAPI.Framework.ModLoading states[mod] = ModDependencyStatus.Checking; // recursively sort dependencies - foreach (var dependency in dependencies) + foreach (ModDependency dependency in dependencies) { - IModMetadata requiredMod = dependency.Mod; - var subchain = new List<IModMetadata>(currentChain) { mod }; - - // ignore missing optional dependency - if (!dependency.IsRequired && requiredMod == null) - continue; + IModMetadata? requiredMod = dependency.Mod; + if (requiredMod == null) + continue; // missing dependencies are handled earlier // detect dependency loop + var subchain = new List<IModMetadata>(currentChain) { mod }; if (states[requiredMod] == ModDependencyStatus.Checking) { sortedMods.Push(mod); @@ -364,8 +362,8 @@ namespace StardewModdingAPI.Framework.ModLoading } // recursively process each dependency - var substatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain); - switch (substatus) + var subStatus = this.ProcessDependencies(mods, modDatabase, requiredMod, states, sortedMods, subchain); + switch (subStatus) { // sorted successfully case ModDependencyStatus.Sorted: @@ -381,7 +379,7 @@ namespace StardewModdingAPI.Framework.ModLoading // unexpected status case ModDependencyStatus.Queued: case ModDependencyStatus.Checking: - throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{substatus}' status."); + throw new InvalidModStateException($"Something went wrong sorting dependencies: mod '{requiredMod.DisplayName}' unexpectedly stayed in the '{subStatus}' status."); // sanity check default: @@ -395,35 +393,16 @@ namespace StardewModdingAPI.Framework.ModLoading } } - /// <summary>Get all mod folders in a root folder, passing through empty folders as needed.</summary> - /// <param name="rootPath">The root folder path to search.</param> - private IEnumerable<DirectoryInfo> GetModFolders(string rootPath) - { - foreach (string modRootPath in Directory.GetDirectories(rootPath)) - { - DirectoryInfo directory = new DirectoryInfo(modRootPath); - - // if a folder only contains another folder, check the inner folder instead - while (!directory.GetFiles().Any() && directory.GetDirectories().Length == 1) - directory = directory.GetDirectories().First(); - - yield return directory; - } - } - /// <summary>Get the dependencies declared in a manifest.</summary> /// <param name="manifest">The mod manifest.</param> /// <param name="loadedMods">The loaded mods.</param> private IEnumerable<ModDependency> GetDependenciesFrom(IManifest manifest, IModMetadata[] loadedMods) { - IModMetadata FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); + IModMetadata? FindMod(string id) => loadedMods.FirstOrDefault(m => m.HasID(id)); // yield dependencies - if (manifest.Dependencies != null) - { - foreach (var entry in manifest.Dependencies) - yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); - } + foreach (IManifestDependency entry in manifest.Dependencies) + yield return new ModDependency(entry.UniqueID, entry.MinimumVersion, FindMod(entry.UniqueID), entry.IsRequired); // yield content pack parent if (manifest.ContentPackFor != null) @@ -432,10 +411,10 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get a technical message indicating why a mod's compatibility status was overridden, if applicable.</summary> /// <param name="mod">The mod metadata.</param> - private string GetTechnicalReasonForStatusOverride(IModMetadata mod) + private string? GetTechnicalReasonForStatusOverride(IModMetadata mod) { // get compatibility list record - var data = mod.DataRecord; + ModDataRecordVersionedFields? data = mod.DataRecord; if (data == null) return null; @@ -449,14 +428,14 @@ namespace StardewModdingAPI.Framework.ModLoading }; // get reason - string[] reasons = new[] { mod.DataRecord.StatusReasonPhrase, mod.DataRecord.StatusReasonDetails } + string?[] reasons = new[] { data.StatusReasonPhrase, data.StatusReasonDetails } .Where(p => !string.IsNullOrWhiteSpace(p)) .ToArray(); // build message return $"marked {statusLabel} in SMAPI's internal compatibility list for " - + (mod.DataRecord.StatusUpperVersion != null ? $"versions up to {mod.DataRecord.StatusUpperVersion}" : "all versions") + + (data.StatusUpperVersion != null ? $"versions up to {data.StatusUpperVersion}" : "all versions") + ": " + (reasons.Any() ? string.Join(": ", reasons) : "no reason given") + "."; @@ -476,13 +455,13 @@ namespace StardewModdingAPI.Framework.ModLoading public string ID { get; } /// <summary>The minimum required version (if any).</summary> - public ISemanticVersion MinVersion { get; } + public ISemanticVersion? MinVersion { get; } /// <summary>Whether the mod shouldn't be loaded if the dependency isn't available.</summary> public bool IsRequired { get; } /// <summary>The loaded mod that fulfills the dependency (if available).</summary> - public IModMetadata Mod { get; } + public IModMetadata? Mod { get; } /********* @@ -493,7 +472,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="minVersion">The minimum required version (if any).</param> /// <param name="mod">The loaded mod that fulfills the dependency (if available).</param> /// <param name="isRequired">Whether the mod shouldn't be loaded if the dependency isn't available.</param> - public ModDependency(string id, ISemanticVersion minVersion, IModMetadata mod, bool isRequired) + public ModDependency(string id, ISemanticVersion? minVersion, IModMetadata? mod, bool isRequired) { this.ID = id; this.MinVersion = minVersion; diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs index be2a1c58..afe38bfd 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/AccessToolsFacade.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades /********* ** Public methods *********/ - public static ConstructorInfo DeclaredConstructor(Type type, Type[] parameters = null) + public static ConstructorInfo DeclaredConstructor(Type type, Type[]? parameters = null) { // Harmony 1.x matched both static and instance constructors return @@ -25,7 +25,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades ?? AccessTools.DeclaredConstructor(type, parameters, searchForStatic: true); } - public static ConstructorInfo Constructor(Type type, Type[] parameters = null) + public static ConstructorInfo Constructor(Type type, Type[]? parameters = null) { // Harmony 1.x matched both static and instance constructors return diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs index 135bd218..9c8ba2b0 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyInstanceFacade.cs @@ -28,7 +28,8 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades return new Harmony(id); } - public DynamicMethod Patch(MethodBase original, HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null) + [SuppressMessage("ReSharper", "ConditionIsAlwaysTrueOrFalse", Justification = "If the user passes a null original method, we let it fail in the underlying Harmony instance instead of handling it here.")] + public DynamicMethod Patch(MethodBase original, HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null) { // In Harmony 1.x you could target a virtual method that's not implemented by the // target type, but in Harmony 2.0 you need to target the concrete implementation. @@ -58,7 +59,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades /// <param name="prefix">The prefix method, if any.</param> /// <param name="postfix">The postfix method, if any.</param> /// <param name="transpiler">The transpiler method, if any.</param> - private string GetPatchTypesLabel(HarmonyMethod prefix = null, HarmonyMethod postfix = null, HarmonyMethod transpiler = null) + private string GetPatchTypesLabel(HarmonyMethod? prefix = null, HarmonyMethod? postfix = null, HarmonyMethod? transpiler = null) { var patchTypes = new List<string>(); @@ -74,7 +75,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades /// <summary>Get a human-readable label for the method being patched.</summary> /// <param name="method">The method being patched.</param> - private string GetMethodLabel(MethodBase method) + private string GetMethodLabel(MethodBase? method) { return method != null ? $"method {method.DeclaringType?.FullName}.{method.Name}" diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs index 5162dda4..2b1ca54b 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/HarmonyMethodFacade.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades this.ImportMethodImpl(method); } - public HarmonyMethodFacade(Type type, string name, Type[] parameters = null) + public HarmonyMethodFacade(Type type, string name, Type[]? parameters = null) { this.ImportMethodImpl(AccessTools.Method(type, name, parameters)); } @@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades // internal code still handles null fine. For backwards compatibility, this bypasses // the new restriction when the mod hasn't been updated for Harmony 2.0 yet. - MethodInfo importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic); + MethodInfo? importMethod = typeof(HarmonyMethod).GetMethod("ImportMethod", BindingFlags.Instance | BindingFlags.NonPublic); if (importMethod == null) throw new InvalidOperationException("Can't find 'HarmonyMethod.ImportMethod' method"); importMethod.Invoke(this, new object[] { methodInfo }); diff --git a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs index 5f68f8d9..67569424 100644 --- a/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs +++ b/src/SMAPI/Framework/ModLoading/RewriteFacades/SpriteBatchFacade.cs @@ -18,7 +18,8 @@ namespace StardewModdingAPI.Framework.ModLoading.RewriteFacades ** Public methods *********/ /// <summary>Construct an instance.</summary> - public SpriteBatchFacade(GraphicsDevice graphicsDevice) : base(graphicsDevice) { } + public SpriteBatchFacade(GraphicsDevice graphicsDevice) + : base(graphicsDevice) { } /**** diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs index 857a2230..d5f4cf4a 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/FieldReplaceRewriter.cs @@ -31,13 +31,19 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="toFieldName">The new field name to reference.</param> public FieldReplaceRewriter AddField(Type fromType, string fromFieldName, Type toType, string toFieldName) { + // validate parameters + if (fromType == null) + throw new InvalidOperationException("Can't replace a field on a null source type."); + if (toType == null) + throw new InvalidOperationException("Can't replace a field on a null target type."); + // get full type name - string fromTypeName = fromType?.FullName; + string? fromTypeName = fromType.FullName; if (fromTypeName == null) throw new InvalidOperationException($"Can't replace field for invalid type reference {toType}."); // get target field - FieldInfo toField = toType.GetField(toFieldName); + FieldInfo? toField = toType.GetField(toFieldName); if (toField == null) throw new InvalidOperationException($"The {toType.FullName} class doesn't have a {toFieldName} field."); @@ -52,15 +58,15 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <inheritdoc /> public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); - string declaringType = fieldRef?.DeclaringType?.FullName; + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); + string? declaringType = fieldRef?.DeclaringType?.FullName; // get mapped field - if (declaringType == null || !this.FieldMaps.TryGetValue(declaringType, out var fieldMap) || !fieldMap.TryGetValue(fieldRef.Name, out FieldInfo toField)) + if (declaringType == null || !this.FieldMaps.TryGetValue(declaringType, out var fieldMap) || !fieldMap.TryGetValue(fieldRef!.Name, out FieldInfo? toField)) return false; // replace with new field - this.Phrases.Add($"{fieldRef.DeclaringType.Name}.{fieldRef.Name} field"); + this.Phrases.Add($"{fieldRef.DeclaringType!.Name}.{fieldRef.Name} field"); instruction.Operand = module.ImportReference(toField); return this.MarkRewritten(); } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs index 922d4bc4..aea490c8 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HarmonyRewriter.cs @@ -34,7 +34,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public override bool Handle(ModuleDefinition module, TypeReference type, Action<TypeReference> replaceWith) { // detect Harmony - if (!(type.Scope is AssemblyNameReference scope) || scope.Name != "0Harmony") + if (type.Scope is not AssemblyNameReference scope || scope.Name != "0Harmony") return false; // rewrite Harmony 1.x type to Harmony 2.0 type @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters if (this.ShouldRewrite) { // rewrite Harmony 1.x methods to Harmony 2.0 - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (this.TryRewriteMethodsToFacade(module, methodRef)) { this.OnChanged(); @@ -65,7 +65,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters } // rewrite renamed fields - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef != null) { if (fieldRef.DeclaringType.FullName == "HarmonyLib.HarmonyMethod" && fieldRef.Name == "prioritiy") @@ -93,13 +93,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <summary>Rewrite methods to use Harmony facades if needed.</summary> /// <param name="module">The assembly module containing the method reference.</param> /// <param name="methodRef">The method reference to map.</param> - private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference methodRef) + private bool TryRewriteMethodsToFacade(ModuleDefinition module, MethodReference? methodRef) { if (!this.ReplacedTypes) return false; // not Harmony (or already using Harmony 2.0) // get facade type - Type toType = methodRef?.DeclaringType.FullName switch + Type? toType = methodRef?.DeclaringType.FullName switch { "HarmonyLib.Harmony" => typeof(HarmonyInstanceFacade), "HarmonyLib.AccessTools" => typeof(AccessToolsFacade), @@ -110,9 +110,9 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; // map if there's a matching method - if (RewriteHelper.HasMatchingSignature(toType, methodRef)) + if (RewriteHelper.HasMatchingSignature(toType, methodRef!)) { - methodRef.DeclaringType = module.ImportReference(toType); + methodRef!.DeclaringType = module.ImportReference(toType); return true; } @@ -137,7 +137,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters { string fullName = type.FullName.Replace("Harmony.", "HarmonyLib."); string targetName = typeof(Harmony).AssemblyQualifiedName!.Replace(typeof(Harmony).FullName!, fullName); - return Type.GetType(targetName, throwOnError: true); + return Type.GetType(targetName, throwOnError: true)!; } } } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs index 57f1dd17..9c6a3980 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicFieldRewriter.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; @@ -31,17 +32,17 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // get field ref - FieldReference fieldRef = RewriteHelper.AsFieldReference(instruction); + FieldReference? fieldRef = RewriteHelper.AsFieldReference(instruction); if (fieldRef == null || !this.ShouldValidate(fieldRef.DeclaringType)) return false; // skip if not broken - FieldDefinition fieldDefinition = fieldRef.Resolve(); - if (fieldDefinition != null && !fieldDefinition.HasConstant) + FieldDefinition? fieldDefinition = fieldRef.Resolve(); + if (fieldDefinition?.HasConstant == false) return false; // rewrite if possible - TypeDefinition declaringType = fieldRef.DeclaringType.Resolve(); + TypeDefinition? declaringType = fieldRef.DeclaringType.Resolve(); bool isRead = instruction.OpCode == OpCodes.Ldsfld || instruction.OpCode == OpCodes.Ldfld; return this.TryRewriteToProperty(module, instruction, fieldRef, declaringType, isRead) @@ -54,7 +55,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters *********/ /// <summary>Whether references to the given type should be validated.</summary> /// <param name="type">The type reference.</param> - private bool ShouldValidate(TypeReference type) + private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) { return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name); } @@ -68,8 +69,8 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters private bool TryRewriteToProperty(ModuleDefinition module, Instruction instruction, FieldReference fieldRef, TypeDefinition declaringType, bool isRead) { // get equivalent property - PropertyDefinition property = declaringType?.Properties.FirstOrDefault(p => p.Name == fieldRef.Name); - MethodDefinition method = isRead ? property?.GetMethod : property?.SetMethod; + PropertyDefinition? property = declaringType?.Properties.FirstOrDefault(p => p.Name == fieldRef.Name); + MethodDefinition? method = isRead ? property?.GetMethod : property?.SetMethod; if (method == null) return false; @@ -84,14 +85,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <summary>Try rewriting the field into a matching const field.</summary> /// <param name="instruction">The CIL instruction to rewrite.</param> /// <param name="field">The field definition.</param> - private bool TryRewriteToConstField(Instruction instruction, FieldDefinition field) + private bool TryRewriteToConstField(Instruction instruction, FieldDefinition? field) { // must have been a static field read, and the new field must be const if (instruction.OpCode != OpCodes.Ldsfld || field?.HasConstant != true) return false; // get opcode for value type - Instruction loadInstruction = RewriteHelper.GetLoadValueInstruction(field.Constant); + Instruction? loadInstruction = RewriteHelper.GetLoadValueInstruction(field.Constant); if (loadInstruction == null) return false; diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs index 89de437e..601ecbbc 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/HeuristicMethodRewriter.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; @@ -31,7 +32,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // get method ref - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (methodRef == null || !this.ShouldValidate(methodRef.DeclaringType)) return false; @@ -40,13 +41,13 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; // get type - var type = methodRef.DeclaringType.Resolve(); + TypeDefinition? type = methodRef.DeclaringType.Resolve(); if (type == null) return false; // get method definition - MethodDefinition method = null; - foreach (var match in type.Methods.Where(p => p.Name == methodRef.Name)) + MethodDefinition? method = null; + foreach (MethodDefinition match in type.Methods.Where(p => p.Name == methodRef.Name)) { // reference matches initial parameters of definition if (methodRef.Parameters.Count >= match.Parameters.Count || !this.InitialParametersMatch(methodRef, match)) @@ -70,7 +71,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters return false; // SMAPI needs to load the value onto the stack before the method call, but the optional parameter type wasn't recognized // rewrite method reference - foreach (Instruction loadInstruction in loadInstructions) + foreach (Instruction? loadInstruction in loadInstructions) cil.InsertBefore(instruction, loadInstruction); instruction.Operand = module.ImportReference(method); @@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters *********/ /// <summary>Whether references to the given type should be validated.</summary> /// <param name="type">The type reference.</param> - private bool ShouldValidate(TypeReference type) + private bool ShouldValidate([NotNullWhen(true)] TypeReference? type) { return type != null && this.RewriteReferencesToAssemblies.Contains(type.Scope.Name); } diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs index 9933e2ca..2e2f6316 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/MethodParentRewriter.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Mono.Cecil; using Mono.Cecil.Cil; @@ -26,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="fromType">The type whose methods to remap.</param> /// <param name="toType">The type with methods to map to.</param> /// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param> - public MethodParentRewriter(string fromType, Type toType, string nounPhrase = null) + public MethodParentRewriter(string fromType, Type toType, string? nounPhrase = null) : base(nounPhrase ?? $"{fromType.Split('.').Last()} methods") { this.FromType = fromType; @@ -37,14 +38,14 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="fromType">The type whose methods to remap.</param> /// <param name="toType">The type with methods to map to.</param> /// <param name="nounPhrase">A brief noun phrase indicating what the instruction finder matches (or <c>null</c> to generate one).</param> - public MethodParentRewriter(Type fromType, Type toType, string nounPhrase = null) - : this(fromType.FullName, toType, nounPhrase) { } + public MethodParentRewriter(Type fromType, Type toType, string? nounPhrase = null) + : this(fromType.FullName!, toType, nounPhrase) { } /// <inheritdoc /> public override bool Handle(ModuleDefinition module, ILProcessor cil, Instruction instruction) { // get method ref - MethodReference methodRef = RewriteHelper.AsMethodReference(instruction); + MethodReference? methodRef = RewriteHelper.AsMethodReference(instruction); if (!this.IsMatch(methodRef)) return false; @@ -59,7 +60,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters *********/ /// <summary>Get whether a CIL instruction matches.</summary> /// <param name="methodRef">The method reference.</param> - private bool IsMatch(MethodReference methodRef) + private bool IsMatch([NotNullWhen(true)] MethodReference? methodRef) { return methodRef != null diff --git a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs index ad5cb96f..a81cb5be 100644 --- a/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs +++ b/src/SMAPI/Framework/ModLoading/Rewriters/TypeReferenceRewriter.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters private readonly Type ToType; /// <summary>Get whether a matched type should be ignored.</summary> - private readonly Func<TypeReference, bool> ShouldIgnore; + private readonly Func<TypeReference, bool>? ShouldIgnore; /********* @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Rewriters /// <param name="fromTypeFullName">The full type name to which to find references.</param> /// <param name="toType">The new type to reference.</param> /// <param name="shouldIgnore">Get whether a matched type should be ignored.</param> - public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool> shouldIgnore = null) + public TypeReferenceRewriter(string fromTypeFullName, Type toType, Func<TypeReference, bool>? shouldIgnore = null) : base($"{fromTypeFullName} type") { this.FromTypeName = fromTypeFullName; diff --git a/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs b/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs index 44074337..0d3aff9f 100644 --- a/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs +++ b/src/SMAPI/Framework/ModLoading/Symbols/SymbolReaderProvider.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols private readonly ISymbolReaderProvider BaseProvider = new DefaultSymbolReaderProvider(throwIfNoSymbol: false); /// <summary>The symbol data loaded by absolute assembly path.</summary> - private readonly Dictionary<string, Stream> SymbolsByAssemblyPath = new Dictionary<string, Stream>(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary<string, Stream> SymbolsByAssemblyPath = new(StringComparer.OrdinalIgnoreCase); /********* @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols /// <param name="fileName">The assembly file name.</param> public ISymbolReader GetSymbolReader(ModuleDefinition module, string fileName) { - return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream symbolData) + return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream? symbolData) ? new SymbolReader(module, symbolData) : this.BaseProvider.GetSymbolReader(module, fileName); } @@ -46,7 +46,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Symbols /// <param name="symbolStream">The loaded symbol file stream.</param> public ISymbolReader GetSymbolReader(ModuleDefinition module, Stream symbolStream) { - return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream symbolData) + return this.SymbolsByAssemblyPath.TryGetValue(module.Name, out Stream? symbolData) ? new SymbolReader(module, symbolData) : this.BaseProvider.GetSymbolReader(module, symbolStream); } diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs index a4ac54e2..d81d763e 100644 --- a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs +++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <c>TKey</c> in the above example). If all components are equal after substitution, and the /// tokens can all be mapped to the same generic type, the types are considered equal. /// </remarks> - internal class TypeReferenceComparer : IEqualityComparer<TypeReference> + internal class TypeReferenceComparer : IEqualityComparer<TypeReference?> { /********* ** Public methods @@ -24,7 +24,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Get whether the specified objects are equal.</summary> /// <param name="a">The first object to compare.</param> /// <param name="b">The second object to compare.</param> - public bool Equals(TypeReference a, TypeReference b) + public bool Equals(TypeReference? a, TypeReference? b) { if (a == null || b == null) return a == b; @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="typeB">The second type to compare.</param> private bool HeuristicallyEquals(TypeReference typeA, TypeReference typeB) { - bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap) + bool HeuristicallyEqualsImpl(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap) { // analyze type names bool hasTokensA = typeNameA.Contains("!"); @@ -80,14 +80,14 @@ namespace StardewModdingAPI.Framework.ModLoading for (int i = 0; i < symbolsA.Length; i++) { - if (!HeuristicallyEquals(symbolsA[i], symbolsB[i], tokenMap)) + if (!HeuristicallyEqualsImpl(symbolsA[i], symbolsB[i], tokenMap)) return false; } return true; } - return HeuristicallyEquals(typeA.FullName, typeB.FullName, new Dictionary<string, string>()); + return HeuristicallyEqualsImpl(typeA.FullName, typeB.FullName, new Dictionary<string, string>()); } /// <summary>Map a generic type placeholder (like <c>!0</c>) to its actual type.</summary> @@ -97,7 +97,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <returns>Returns the previously-mapped type if applicable, else the <paramref name="type"/>.</returns> private string MapPlaceholder(string placeholder, string type, IDictionary<string, string> map) { - if (map.TryGetValue(placeholder, out string result)) + if (map.TryGetValue(placeholder, out string? result)) return result; map[placeholder] = type; diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index ef389337..1ae5643f 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Framework ** Fields *********/ /// <summary>The registered mod data.</summary> - private readonly List<IModMetadata> Mods = new List<IModMetadata>(); + private readonly List<IModMetadata> Mods = new(); /// <summary>An assembly full name => mod lookup.</summary> private readonly IDictionary<string, IModMetadata> ModNamesByAssembly = new Dictionary<string, IModMetadata>(); @@ -35,12 +35,12 @@ namespace StardewModdingAPI.Framework this.Mods.Add(metadata); } - /// <summary>Track a mod's assembly for use via <see cref="GetFrom"/>.</summary> + /// <summary>Track a mod's assembly for use via <see cref="GetFrom(Type?)"/>.</summary> /// <param name="metadata">The mod metadata.</param> /// <param name="modAssembly">The mod assembly.</param> public void TrackAssemblies(IModMetadata metadata, Assembly modAssembly) { - this.ModNamesByAssembly[modAssembly.FullName] = metadata; + this.ModNamesByAssembly[modAssembly.FullName!] = metadata; } /// <summary>Get metadata for all loaded mods.</summary> @@ -59,8 +59,8 @@ namespace StardewModdingAPI.Framework /// <summary>Get metadata for a loaded mod.</summary> /// <param name="uniqueID">The mod's unique ID.</param> - /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> - public IModMetadata Get(string uniqueID) + /// <returns>Returns the mod's metadata, or <c>null</c> if not found.</returns> + public IModMetadata? Get(string uniqueID) { // normalize search ID if (string.IsNullOrWhiteSpace(uniqueID)) @@ -73,15 +73,15 @@ namespace StardewModdingAPI.Framework /// <summary>Get the mod metadata from one of its assemblies.</summary> /// <param name="type">The type to check.</param> - /// <returns>Returns the mod name, or <c>null</c> if the type isn't part of a known mod.</returns> - public IModMetadata GetFrom(Type type) + /// <returns>Returns the mod's metadata, or <c>null</c> if the type isn't part of a known mod.</returns> + public IModMetadata? GetFrom(Type? type) { // null if (type == null) return null; // known type - string assemblyName = type.Assembly.FullName; + string assemblyName = type.Assembly.FullName!; if (this.ModNamesByAssembly.ContainsKey(assemblyName)) return this.ModNamesByAssembly[assemblyName]; @@ -89,21 +89,27 @@ namespace StardewModdingAPI.Framework return null; } - /// <summary>Get the friendly name for the closest assembly registered as a source of deprecation warnings.</summary> - /// <returns>Returns the source name, or <c>null</c> if no registered assemblies were found.</returns> - public IModMetadata GetFromStack() + /// <summary>Get the mod metadata from a stack frame, if any.</summary> + /// <param name="frame">The stack frame to check.</param> + /// <returns>Returns the mod's metadata, or <c>null</c> if the frame isn't part of a known mod.</returns> + public IModMetadata? GetFrom(StackFrame frame) + { + MethodBase? method = frame.GetMethod(); + return this.GetFrom(method?.ReflectedType); + } + + /// <summary>Get the mod metadata from the closest assembly registered as a source of deprecation warnings.</summary> + /// <returns>Returns the mod's metadata, or <c>null</c> if no registered assemblies were found.</returns> + public IModMetadata? GetFromStack() { // get stack frames - StackTrace stack = new StackTrace(); + StackTrace stack = new(); StackFrame[] frames = stack.GetFrames(); - if (frames == null) - return null; // search stack for a source assembly foreach (StackFrame frame in frames) { - MethodBase method = frame.GetMethod(); - IModMetadata mod = this.GetFrom(method.ReflectedType); + IModMetadata? mod = this.GetFrom(frame); if (mod != null) return mod; } diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index 10bf9f94..1a43c1fc 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -22,11 +22,12 @@ namespace StardewModdingAPI.Framework.Models [nameof(VerboseLogging)] = false, [nameof(LogNetworkTraffic)] = false, [nameof(RewriteMods)] = true, - [nameof(AggressiveMemoryOptimizations)] = false + [nameof(AggressiveMemoryOptimizations)] = false, + [nameof(UsePintail)] = true }; /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> - private static readonly HashSet<string> DefaultSuppressUpdateChecks = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + private static readonly HashSet<string> DefaultSuppressUpdateChecks = new(StringComparer.OrdinalIgnoreCase) { "SMAPI.ConsoleCommands", "SMAPI.ErrorHandler", @@ -38,63 +39,101 @@ namespace StardewModdingAPI.Framework.Models ** Accessors ********/ /// <summary>Whether to enable development features.</summary> - public bool DeveloperMode { get; set; } + public bool DeveloperMode { get; private set; } /// <summary>Whether to check for newer versions of SMAPI and mods on startup.</summary> - public bool CheckForUpdates { get; set; } + public bool CheckForUpdates { get; } /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> - public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; + public bool ParanoidWarnings { get; } /// <summary>Whether to show beta versions as valid updates.</summary> - public bool UseBetaChannel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)]; + public bool UseBetaChannel { get; } /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> - public string GitHubProjectName { get; set; } - - /// <summary>Stardew64Installer's GitHub project name, used to perform update checks.</summary> - public string Stardew64InstallerGitHubProjectName { get; set; } + public string GitHubProjectName { get; } /// <summary>The base URL for SMAPI's web API, used to perform update checks.</summary> - public string WebApiBaseUrl { get; set; } + public string WebApiBaseUrl { get; } /// <summary>Whether SMAPI should log more information about the game context.</summary> - public bool VerboseLogging { get; set; } + public bool VerboseLogging { get; } /// <summary>Whether SMAPI should rewrite mods for compatibility.</summary> - public bool RewriteMods { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)]; + public bool RewriteMods { get; } /// <summary>Whether to enable more aggressive memory optimizations.</summary> - public bool AggressiveMemoryOptimizations { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.AggressiveMemoryOptimizations)]; + public bool AggressiveMemoryOptimizations { get; } + + /// <summary>Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</summary> + public bool UsePintail { get; } /// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary> - public bool LogNetworkTraffic { get; set; } + public bool LogNetworkTraffic { get; } /// <summary>The colors to use for text written to the SMAPI console.</summary> - public ColorSchemeConfig ConsoleColors { get; set; } + public ColorSchemeConfig ConsoleColors { get; } /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary> - public string[] SuppressUpdateChecks { get; set; } + public string[] SuppressUpdateChecks { get; } /******** ** Public methods ********/ + /// <summary>Construct an instance.</summary> + /// <param name="developerMode">Whether to enable development features.</param> + /// <param name="checkForUpdates">Whether to check for newer versions of SMAPI and mods on startup.</param> + /// <param name="paranoidWarnings">Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</param> + /// <param name="useBetaChannel">Whether to show beta versions as valid updates.</param> + /// <param name="gitHubProjectName">SMAPI's GitHub project name, used to perform update checks.</param> + /// <param name="webApiBaseUrl">The base URL for SMAPI's web API, used to perform update checks.</param> + /// <param name="verboseLogging">Whether SMAPI should log more information about the game context.</param> + /// <param name="rewriteMods">Whether SMAPI should rewrite mods for compatibility.</param> + /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> + /// <param name="usePintail">Whether to use the experimental Pintail API proxying library, instead of the original proxying built into SMAPI itself.</param> + /// <param name="logNetworkTraffic">Whether SMAPI should log network traffic.</param> + /// <param name="consoleColors">The colors to use for text written to the SMAPI console.</param> + /// <param name="suppressUpdateChecks">The mod IDs SMAPI should ignore when performing update checks or validating update keys.</param> + public SConfig(bool developerMode, bool checkForUpdates, bool? paranoidWarnings, bool? useBetaChannel, string gitHubProjectName, string webApiBaseUrl, bool verboseLogging, bool? rewriteMods, bool? aggressiveMemoryOptimizations, bool usePintail, bool logNetworkTraffic, ColorSchemeConfig consoleColors, string[]? suppressUpdateChecks) + { + this.DeveloperMode = developerMode; + this.CheckForUpdates = checkForUpdates; + this.ParanoidWarnings = paranoidWarnings ?? (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; + this.UseBetaChannel = useBetaChannel ?? (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)]; + this.GitHubProjectName = gitHubProjectName; + this.WebApiBaseUrl = webApiBaseUrl; + this.VerboseLogging = verboseLogging; + this.RewriteMods = rewriteMods ?? (bool)SConfig.DefaultValues[nameof(SConfig.RewriteMods)]; + this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations ?? (bool)SConfig.DefaultValues[nameof(SConfig.AggressiveMemoryOptimizations)]; + this.UsePintail = usePintail; + this.LogNetworkTraffic = logNetworkTraffic; + this.ConsoleColors = consoleColors; + this.SuppressUpdateChecks = suppressUpdateChecks ?? Array.Empty<string>(); + } + + /// <summary>Override the value of <see cref="DeveloperMode"/>.</summary> + /// <param name="value">The value to set.</param> + public void OverrideDeveloperMode(bool value) + { + this.DeveloperMode = value; + } + /// <summary>Get the settings which have been customized by the player.</summary> - public IDictionary<string, object> GetCustomSettings() + public IDictionary<string, object?> GetCustomSettings() { - IDictionary<string, object> custom = new Dictionary<string, object>(); + Dictionary<string, object?> custom = new(); - foreach (var pair in SConfig.DefaultValues) + foreach ((string? name, object defaultValue) in SConfig.DefaultValues) { - object value = typeof(SConfig).GetProperty(pair.Key)?.GetValue(this); - if (!pair.Value.Equals(value)) - custom[pair.Key] = value; + object? value = typeof(SConfig).GetProperty(name)?.GetValue(this); + if (!defaultValue.Equals(value)) + custom[name] = value; } - HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.OrdinalIgnoreCase); + HashSet<string> curSuppressUpdateChecks = new(this.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase); if (SConfig.DefaultSuppressUpdateChecks.Count != curSuppressUpdateChecks.Count || SConfig.DefaultSuppressUpdateChecks.Any(p => !curSuppressUpdateChecks.Contains(p))) - custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? new string[0]) + "]"; + custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks) + "]"; return custom; } diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index ab76e7c0..6b53daff 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -92,7 +92,7 @@ namespace StardewModdingAPI.Framework public void VerboseLog(string message) { if (this.IsVerbose) - this.Log(message, LogLevel.Trace); + this.Log(message); } /// <summary>Write a newline to the console and log file.</summary> diff --git a/src/SMAPI/Framework/Networking/ModMessageModel.cs b/src/SMAPI/Framework/Networking/ModMessageModel.cs index 4f694f9c..01672714 100644 --- a/src/SMAPI/Framework/Networking/ModMessageModel.cs +++ b/src/SMAPI/Framework/Networking/ModMessageModel.cs @@ -1,4 +1,5 @@ using System.Linq; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace StardewModdingAPI.Framework.Networking @@ -13,41 +14,39 @@ namespace StardewModdingAPI.Framework.Networking ** Origin ****/ /// <summary>The unique ID of the player who broadcast the message.</summary> - public long FromPlayerID { get; set; } + public long FromPlayerID { get; } /// <summary>The unique ID of the mod which broadcast the message.</summary> - public string FromModID { get; set; } + public string FromModID { get; } /**** ** Destination ****/ /// <summary>The players who should receive the message.</summary> - public long[] ToPlayerIDs { get; set; } + public long[]? ToPlayerIDs { get; init; } /// <summary>The mods which should receive the message, or <c>null</c> for all mods.</summary> - public string[] ToModIDs { get; set; } + public string[]? ToModIDs { get; } /// <summary>A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</summary> - public string Type { get; set; } + public string Type { get; } /// <summary>The custom mod data being broadcast.</summary> - public JToken Data { get; set; } + public JToken Data { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - public ModMessageModel() { } - - /// <summary>Construct an instance.</summary> /// <param name="fromPlayerID">The unique ID of the player who broadcast the message.</param> /// <param name="fromModID">The unique ID of the mod which broadcast the message.</param> /// <param name="toPlayerIDs">The players who should receive the message, or <c>null</c> for all players.</param> /// <param name="toModIDs">The mods which should receive the message, or <c>null</c> for all mods.</param> /// <param name="type">A message type which receiving mods can use to decide whether it's the one they want to handle, like <c>SetPlayerLocation</c>. This doesn't need to be globally unique, since mods should check the originating mod ID.</param> /// <param name="data">The custom mod data being broadcast.</param> - public ModMessageModel(long fromPlayerID, string fromModID, long[] toPlayerIDs, string[] toModIDs, string type, JToken data) + [JsonConstructor] + public ModMessageModel(long fromPlayerID, string fromModID, long[]? toPlayerIDs, string[]? toModIDs, string type, JToken data) { this.FromPlayerID = fromPlayerID; this.FromModID = fromModID; diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs index 3923700f..b37c1e89 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeer.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeer.cs @@ -37,10 +37,10 @@ namespace StardewModdingAPI.Framework.Networking public GamePlatform? Platform { get; } /// <inheritdoc /> - public ISemanticVersion GameVersion { get; } + public ISemanticVersion? GameVersion { get; } /// <inheritdoc /> - public ISemanticVersion ApiVersion { get; } + public ISemanticVersion? ApiVersion { get; } /// <inheritdoc /> public IEnumerable<IMultiplayerPeerMod> Mods { get; } @@ -55,11 +55,12 @@ namespace StardewModdingAPI.Framework.Networking /// <param name="model">The metadata to copy.</param> /// <param name="sendMessage">A method which sends a message to the peer.</param> /// <param name="isHost">Whether this is a connection to the host player.</param> - public MultiplayerPeer(long playerID, int? screenID, RemoteContextModel model, Action<OutgoingMessage> sendMessage, bool isHost) + public MultiplayerPeer(long playerID, int? screenID, RemoteContextModel? model, Action<OutgoingMessage> sendMessage, bool isHost) { this.PlayerID = playerID; this.ScreenID = screenID; this.IsHost = isHost; + if (model != null) { this.Platform = model.Platform; @@ -67,13 +68,16 @@ namespace StardewModdingAPI.Framework.Networking this.ApiVersion = model.ApiVersion; this.Mods = model.Mods.Select(mod => new MultiplayerPeerMod(mod)).ToArray(); } + else + this.Mods = Array.Empty<IMultiplayerPeerMod>(); + this.SendMessageImpl = sendMessage; } /// <inheritdoc /> - public IMultiplayerPeerMod GetMod(string id) + public IMultiplayerPeerMod? GetMod(string? id) { - if (string.IsNullOrWhiteSpace(id) || this.Mods == null || !this.Mods.Any()) + if (string.IsNullOrWhiteSpace(id) || !this.Mods.Any()) return null; id = id.Trim(); diff --git a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs index 8087dc7e..1e150508 100644 --- a/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs +++ b/src/SMAPI/Framework/Networking/MultiplayerPeerMod.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace StardewModdingAPI.Framework.Networking { internal class MultiplayerPeerMod : IMultiplayerPeerMod @@ -20,10 +22,11 @@ namespace StardewModdingAPI.Framework.Networking *********/ /// <summary>Construct an instance.</summary> /// <param name="mod">The mod metadata.</param> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The ID shouldn't be null, but we should handle it to avoid an error just in case.")] public MultiplayerPeerMod(RemoteContextModModel mod) { this.Name = mod.Name; - this.ID = mod.ID?.Trim(); + this.ID = mod.ID?.Trim() ?? string.Empty; this.Version = mod.Version; } } diff --git a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs index 9795d971..7571acba 100644 --- a/src/SMAPI/Framework/Networking/RemoteContextModModel.cs +++ b/src/SMAPI/Framework/Networking/RemoteContextModModel.cs @@ -3,13 +3,31 @@ namespace StardewModdingAPI.Framework.Networking /// <summary>Metadata about an installed mod exchanged with connected computers.</summary> public class RemoteContextModModel { - /// <summary>The mod's display name.</summary> - public string Name { get; set; } - + /********* + ** Accessors + *********/ /// <summary>The unique mod ID.</summary> - public string ID { get; set; } + public string ID { get; } + + /// <summary>The mod's display name.</summary> + public string Name { get; } /// <summary>The mod version.</summary> - public ISemanticVersion Version { get; set; } + public ISemanticVersion Version { get; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="id">The unique mod ID.</param> + /// <param name="name">The mod's display name.</param> + /// <param name="version">The mod version.</param> + public RemoteContextModModel(string id, string name, ISemanticVersion version) + { + this.ID = id; + this.Name = name; + this.Version = version; + } } } diff --git a/src/SMAPI/Framework/Networking/RemoteContextModel.cs b/src/SMAPI/Framework/Networking/RemoteContextModel.cs index 7befb151..7d53e732 100644 --- a/src/SMAPI/Framework/Networking/RemoteContextModel.cs +++ b/src/SMAPI/Framework/Networking/RemoteContextModel.cs @@ -1,3 +1,5 @@ +using System; + namespace StardewModdingAPI.Framework.Networking { /// <summary>Metadata about the game, SMAPI, and installed mods exchanged with connected computers.</summary> @@ -7,18 +9,37 @@ namespace StardewModdingAPI.Framework.Networking ** Accessors *********/ /// <summary>Whether this player is the host player.</summary> - public bool IsHost { get; set; } + public bool IsHost { get; } - /// <summary>The game's platform version.</summary> - public GamePlatform Platform { get; set; } + /// <summary>The game's platform.</summary> + public GamePlatform Platform { get; } /// <summary>The installed version of Stardew Valley.</summary> - public ISemanticVersion GameVersion { get; set; } + public ISemanticVersion? GameVersion { get; } /// <summary>The installed version of SMAPI.</summary> - public ISemanticVersion ApiVersion { get; set; } + public ISemanticVersion? ApiVersion { get; } /// <summary>The installed mods.</summary> - public RemoteContextModModel[] Mods { get; set; } + public RemoteContextModModel[] Mods { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="isHost">Whether this player is the host player.</param> + /// <param name="platform">The game's platform.</param> + /// <param name="gameVersion">The installed version of Stardew Valley.</param> + /// <param name="apiVersion">The installed version of SMAPI.</param> + /// <param name="mods">The installed mods.</param> + public RemoteContextModel(bool isHost, GamePlatform platform, ISemanticVersion gameVersion, ISemanticVersion apiVersion, RemoteContextModModel[]? mods) + { + this.IsHost = isHost; + this.Platform = platform; + this.GameVersion = gameVersion; + this.ApiVersion = apiVersion; + this.Mods = mods ?? Array.Empty<RemoteContextModModel>(); + } } } diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index ac9cf313..71e11576 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -45,8 +45,8 @@ namespace StardewModdingAPI.Framework.Networking [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] protected override void onReceiveMessage(GalaxyID peer, Stream messageStream) { - using IncomingMessage message = new IncomingMessage(); - using BinaryReader reader = new BinaryReader(messageStream); + using IncomingMessage message = new(); + using BinaryReader reader = new(messageStream); message.Read(reader); ulong peerID = peer.ToUint64(); // note: GalaxyID instances get reused, so need to store the underlying ID instead @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Framework.Networking else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - GalaxyID capturedPeer = new GalaxyID(peerID); + GalaxyID capturedPeer = new(peerID); this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), this.getConnectionId(peer), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); } }); diff --git a/src/SMAPI/Framework/Networking/SLidgrenServer.cs b/src/SMAPI/Framework/Networking/SLidgrenServer.cs index 05c8b872..ff871e64 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -44,9 +44,9 @@ namespace StardewModdingAPI.Framework.Networking { // add hook to call multiplayer core NetConnection peer = rawMessage.SenderConnection; - using IncomingMessage message = new IncomingMessage(); + using IncomingMessage message = new(); using Stream readStream = new NetBufferReadStream(rawMessage); - using BinaryReader reader = new BinaryReader(readStream); + using BinaryReader reader = new(readStream); while (rawMessage.LengthBits - rawMessage.Position >= 8) { diff --git a/src/SMAPI/Framework/Reflection/CacheEntry.cs b/src/SMAPI/Framework/Reflection/CacheEntry.cs index 912662e3..27f48a1f 100644 --- a/src/SMAPI/Framework/Reflection/CacheEntry.cs +++ b/src/SMAPI/Framework/Reflection/CacheEntry.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Reflection; namespace StardewModdingAPI.Framework.Reflection @@ -9,21 +10,20 @@ namespace StardewModdingAPI.Framework.Reflection ** Accessors *********/ /// <summary>Whether the lookup found a valid match.</summary> - public bool IsValid { get; } + [MemberNotNullWhen(true, nameof(CacheEntry.MemberInfo))] + public bool IsValid => this.MemberInfo != null; /// <summary>The reflection data for this member (or <c>null</c> if invalid).</summary> - public MemberInfo MemberInfo { get; } + public MemberInfo? MemberInfo { get; } /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="isValid">Whether the lookup found a valid match.</param> /// <param name="memberInfo">The reflection data for this member (or <c>null</c> if invalid).</param> - public CacheEntry(bool isValid, MemberInfo memberInfo) + public CacheEntry(MemberInfo? memberInfo) { - this.IsValid = isValid; this.MemberInfo = memberInfo; } } diff --git a/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs new file mode 100644 index 00000000..6429db58 --- /dev/null +++ b/src/SMAPI/Framework/Reflection/IInterfaceProxyFactory.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Framework.Reflection +{ + /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> + internal interface IInterfaceProxyFactory + { + /********* + ** Methods + *********/ + /// <summary>Create an API proxy.</summary> + /// <typeparam name="TInterface">The interface through which to access the API.</typeparam> + /// <param name="instance">The API instance to access.</param> + /// <param name="sourceModID">The unique ID of the mod consuming the API.</param> + /// <param name="targetModID">The unique ID of the mod providing the API.</param> + TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) + where TInterface : class; + } +} diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs index 5acba569..694c563d 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs +++ b/src/SMAPI/Framework/Reflection/InterfaceProxyFactory.cs @@ -1,21 +1,17 @@ -using System; -using System.Collections.Generic; using System.Reflection; using System.Reflection.Emit; +using Nanoray.Pintail; namespace StardewModdingAPI.Framework.Reflection { - /// <summary>Generates proxy classes to access mod APIs through an arbitrary interface.</summary> - internal class InterfaceProxyFactory + /// <inheritdoc /> + internal class InterfaceProxyFactory : IInterfaceProxyFactory { /********* ** Fields *********/ - /// <summary>The CLR module in which to create proxy classes.</summary> - private readonly ModuleBuilder ModuleBuilder; - - /// <summary>The generated proxy types.</summary> - private readonly IDictionary<string, InterfaceProxyBuilder> Builders = new Dictionary<string, InterfaceProxyBuilder>(); + /// <summary>The underlying proxy type builder.</summary> + private readonly IProxyManager<string> ProxyManager; /********* @@ -25,37 +21,18 @@ namespace StardewModdingAPI.Framework.Reflection public InterfaceProxyFactory() { AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run); - this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); + ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); + this.ProxyManager = new ProxyManager<string>(moduleBuilder, new ProxyManagerConfiguration<string>( + proxyPrepareBehavior: ProxyManagerProxyPrepareBehavior.Eager, + proxyObjectInterfaceMarking: ProxyObjectInterfaceMarking.Disabled + )); } - /// <summary>Create an API proxy.</summary> - /// <typeparam name="TInterface">The interface through which to access the API.</typeparam> - /// <param name="instance">The API instance to access.</param> - /// <param name="sourceModID">The unique ID of the mod consuming the API.</param> - /// <param name="targetModID">The unique ID of the mod providing the API.</param> + /// <inheritdoc /> public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) where TInterface : class { - lock (this.Builders) - { - // validate - if (instance == null) - throw new InvalidOperationException("Can't proxy access to a null API."); - if (!typeof(TInterface).IsInterface) - throw new InvalidOperationException("The proxy type must be an interface, not a class."); - - // get proxy type - Type targetType = instance.GetType(); - string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; - if (!this.Builders.TryGetValue(proxyTypeName, out InterfaceProxyBuilder builder)) - { - builder = new InterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); - this.Builders[proxyTypeName] = builder; - } - - // create instance - return (TInterface)builder.CreateInstance(instance); - } + return this.ProxyManager.ObtainProxy<string, TInterface>(instance, targetContext: targetModID, proxyContext: sourceModID); } } } diff --git a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs index 70ef81f8..9576f768 100644 --- a/src/SMAPI/Framework/Reflection/InterfaceProxyBuilder.cs +++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyBuilder.cs @@ -6,7 +6,7 @@ using System.Reflection.Emit; namespace StardewModdingAPI.Framework.Reflection { /// <summary>Generates a proxy class to access a mod API through an arbitrary interface.</summary> - internal class InterfaceProxyBuilder + internal class OriginalInterfaceProxyBuilder { /********* ** Fields @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Framework.Reflection /// <param name="moduleBuilder">The CLR module in which to create proxy classes.</param> /// <param name="interfaceType">The interface type to implement.</param> /// <param name="targetType">The target type.</param> - public InterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType) + public OriginalInterfaceProxyBuilder(string name, ModuleBuilder moduleBuilder, Type interfaceType, Type targetType) { // validate if (name == null) @@ -48,7 +48,7 @@ namespace StardewModdingAPI.Framework.Reflection il.Emit(OpCodes.Ldarg_0); // this // ReSharper disable once AssignNullToNotNullAttribute -- never null - il.Emit(OpCodes.Call, typeof(object).GetConstructor(new Type[0])); // call base constructor + il.Emit(OpCodes.Call, typeof(object).GetConstructor(Type.EmptyTypes)!); // call base constructor il.Emit(OpCodes.Ldarg_0); // this il.Emit(OpCodes.Ldarg_1); // load argument il.Emit(OpCodes.Stfld, targetField); // set field to loaded argument @@ -67,14 +67,14 @@ namespace StardewModdingAPI.Framework.Reflection // save info this.TargetType = targetType; - this.ProxyType = proxyBuilder.CreateType(); + this.ProxyType = proxyBuilder.CreateType()!; } /// <summary>Create an instance of the proxy for a target instance.</summary> /// <param name="targetInstance">The target instance.</param> public object CreateInstance(object targetInstance) { - ConstructorInfo constructor = this.ProxyType.GetConstructor(new[] { this.TargetType }); + ConstructorInfo? constructor = this.ProxyType.GetConstructor(new[] { this.TargetType }); if (constructor == null) throw new InvalidOperationException($"Couldn't find the constructor for generated proxy type '{this.ProxyType.Name}'."); // should never happen return constructor.Invoke(new[] { targetInstance }); diff --git a/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs new file mode 100644 index 00000000..d6966978 --- /dev/null +++ b/src/SMAPI/Framework/Reflection/OriginalInterfaceProxyFactory.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Reflection.Emit; + +namespace StardewModdingAPI.Framework.Reflection +{ + /// <inheritdoc /> + internal class OriginalInterfaceProxyFactory : IInterfaceProxyFactory + { + /********* + ** Fields + *********/ + /// <summary>The CLR module in which to create proxy classes.</summary> + private readonly ModuleBuilder ModuleBuilder; + + /// <summary>The generated proxy types.</summary> + private readonly IDictionary<string, OriginalInterfaceProxyBuilder> Builders = new Dictionary<string, OriginalInterfaceProxyBuilder>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public OriginalInterfaceProxyFactory() + { + AssemblyBuilder assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName($"StardewModdingAPI.Proxies, Version={this.GetType().Assembly.GetName().Version}, Culture=neutral"), AssemblyBuilderAccess.Run); + this.ModuleBuilder = assemblyBuilder.DefineDynamicModule("StardewModdingAPI.Proxies"); + } + + /// <inheritdoc /> + public TInterface CreateProxy<TInterface>(object instance, string sourceModID, string targetModID) + where TInterface : class + { + lock (this.Builders) + { + // validate + if (instance == null) + throw new InvalidOperationException("Can't proxy access to a null API."); + if (!typeof(TInterface).IsInterface) + throw new InvalidOperationException("The proxy type must be an interface, not a class."); + + // get proxy type + Type targetType = instance.GetType(); + string proxyTypeName = $"StardewModdingAPI.Proxies.From<{sourceModID}_{typeof(TInterface).FullName}>_To<{targetModID}_{targetType.FullName}>"; + if (!this.Builders.TryGetValue(proxyTypeName, out OriginalInterfaceProxyBuilder? builder)) + { + builder = new OriginalInterfaceProxyBuilder(proxyTypeName, this.ModuleBuilder, typeof(TInterface), targetType); + this.Builders[proxyTypeName] = builder; + } + + // create instance + return (TInterface)builder.CreateInstance(instance); + } + } + } +} diff --git a/src/SMAPI/Framework/Reflection/ReflectedField.cs b/src/SMAPI/Framework/Reflection/ReflectedField.cs index 3c4da4fc..a97ca3f0 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedField.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedField.cs @@ -13,8 +13,8 @@ namespace StardewModdingAPI.Framework.Reflection /// <summary>The type that has the field.</summary> private readonly Type ParentType; - /// <summary>The object that has the instance field (if applicable).</summary> - private readonly object Parent; + /// <summary>The object that has the instance field, or <c>null</c> for a static field.</summary> + private readonly object? Parent; /// <summary>The display name shown in error messages.</summary> private string DisplayName => $"{this.ParentType.FullName}::{this.FieldInfo.Name}"; @@ -32,12 +32,12 @@ namespace StardewModdingAPI.Framework.Reflection *********/ /// <summary>Construct an instance.</summary> /// <param name="parentType">The type that has the field.</param> - /// <param name="obj">The object that has the instance field (if applicable).</param> + /// <param name="obj">The object that has the instance field, or <c>null</c> for a static field.</param> /// <param name="field">The reflection metadata.</param> /// <param name="isStatic">Whether the field is static.</param> /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="field"/> is null.</exception> /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static field, or not null for a static field.</exception> - public ReflectedField(Type parentType, object obj, FieldInfo field, bool isStatic) + public ReflectedField(Type parentType, object? obj, FieldInfo field, bool isStatic) { // validate if (parentType == null) @@ -60,7 +60,7 @@ namespace StardewModdingAPI.Framework.Reflection { try { - return (TValue)this.FieldInfo.GetValue(this.Parent); + return (TValue)this.FieldInfo.GetValue(this.Parent)!; } catch (InvalidCastException) { diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs index 26112806..a607141e 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs @@ -12,8 +12,8 @@ namespace StardewModdingAPI.Framework.Reflection /// <summary>The type that has the method.</summary> private readonly Type ParentType; - /// <summary>The object that has the instance method (if applicable).</summary> - private readonly object Parent; + /// <summary>The object that has the instance method, or <c>null</c> for a static method.</summary> + private readonly object? Parent; /// <summary>The display name shown in error messages.</summary> private string DisplayName => $"{this.ParentType.FullName}::{this.MethodInfo.Name}"; @@ -31,12 +31,12 @@ namespace StardewModdingAPI.Framework.Reflection *********/ /// <summary>Construct an instance.</summary> /// <param name="parentType">The type that has the method.</param> - /// <param name="obj">The object that has the instance method(if applicable).</param> + /// <param name="obj">The object that has the instance method, or <c>null</c> for a static method.</param> /// <param name="method">The reflection metadata.</param> /// <param name="isStatic">Whether the method is static.</param> /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="method"/> is null.</exception> /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static method, or not null for a static method.</exception> - public ReflectedMethod(Type parentType, object obj, MethodInfo method, bool isStatic) + public ReflectedMethod(Type parentType, object? obj, MethodInfo method, bool isStatic) { // validate if (parentType == null) @@ -55,10 +55,10 @@ namespace StardewModdingAPI.Framework.Reflection } /// <inheritdoc /> - public TValue Invoke<TValue>(params object[] arguments) + public TValue Invoke<TValue>(params object?[] arguments) { // invoke method - object result; + object? result; try { result = this.MethodInfo.Invoke(this.Parent, arguments); @@ -75,7 +75,7 @@ namespace StardewModdingAPI.Framework.Reflection // cast return value try { - return (TValue)result; + return (TValue)result!; } catch (InvalidCastException) { @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework.Reflection } /// <inheritdoc /> - public void Invoke(params object[] arguments) + public void Invoke(params object?[] arguments) { // invoke method try diff --git a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs index 42d7bb59..72e701d1 100644 --- a/src/SMAPI/Framework/Reflection/ReflectedProperty.cs +++ b/src/SMAPI/Framework/Reflection/ReflectedProperty.cs @@ -14,10 +14,10 @@ namespace StardewModdingAPI.Framework.Reflection private readonly string DisplayName; /// <summary>The underlying property getter.</summary> - private readonly Func<TValue> GetMethod; + private readonly Func<TValue>? GetMethod; /// <summary>The underlying property setter.</summary> - private readonly Action<TValue> SetMethod; + private readonly Action<TValue>? SetMethod; /********* @@ -32,12 +32,12 @@ namespace StardewModdingAPI.Framework.Reflection *********/ /// <summary>Construct an instance.</summary> /// <param name="parentType">The type that has the property.</param> - /// <param name="obj">The object that has the instance property (if applicable).</param> + /// <param name="obj">The object that has the instance property, or <c>null</c> for a static property.</param> /// <param name="property">The reflection metadata.</param> /// <param name="isStatic">Whether the property is static.</param> /// <exception cref="ArgumentNullException">The <paramref name="parentType"/> or <paramref name="property"/> is null.</exception> /// <exception cref="ArgumentException">The <paramref name="obj"/> is null for a non-static property, or not null for a static property.</exception> - public ReflectedProperty(Type parentType, object obj, PropertyInfo property, bool isStatic) + public ReflectedProperty(Type parentType, object? obj, PropertyInfo property, bool isStatic) { // validate input if (parentType == null) diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index 889c7ed6..79575c26 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Reflection; using System.Runtime.Caching; @@ -13,7 +12,7 @@ namespace StardewModdingAPI.Framework.Reflection ** Fields *********/ /// <summary>The cached fields and methods found via reflection.</summary> - private readonly MemoryCache Cache = new MemoryCache(typeof(Reflector).FullName); + private readonly MemoryCache Cache = new(typeof(Reflector).FullName!); /// <summary>The sliding cache expiration time.</summary> private readonly TimeSpan SlidingCacheExpiry = TimeSpan.FromMinutes(5); @@ -29,8 +28,9 @@ namespace StardewModdingAPI.Framework.Reflection /// <typeparam name="TValue">The field type.</typeparam> /// <param name="obj">The object which has the field.</param> /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> - /// <returns>Returns the field wrapper, or <c>null</c> if the field doesn't exist and <paramref name="required"/> is <c>false</c>.</returns> + /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the field wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedField<TValue> GetField<TValue>(object obj, string name, bool required = true) { // validate @@ -38,24 +38,26 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a instance field from a null object."); // get field from hierarchy - IReflectedField<TValue> field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedField<TValue>? field = this.GetFieldFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && field == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance field."); - return field; + return field!; } /// <summary>Get a static field.</summary> /// <typeparam name="TValue">The field type.</typeparam> /// <param name="type">The type which has the field.</param> /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the field wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedField<TValue> GetField<TValue>(Type type, string name, bool required = true) { // get field from hierarchy - IReflectedField<TValue> field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public); + IReflectedField<TValue>? field = this.GetFieldFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Public); if (required && field == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static field."); - return field; + return field!; } /**** @@ -65,7 +67,9 @@ namespace StardewModdingAPI.Framework.Reflection /// <typeparam name="TValue">The property type.</typeparam> /// <param name="obj">The object which has the property.</param> /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the property wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedProperty<TValue> GetProperty<TValue>(object obj, string name, bool required = true) { // validate @@ -73,24 +77,26 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a instance property from a null object."); // get property from hierarchy - IReflectedProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedProperty<TValue>? property = this.GetPropertyFromHierarchy<TValue>(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && property == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance property."); - return property; + return property!; } /// <summary>Get a static property.</summary> /// <typeparam name="TValue">The property type.</typeparam> /// <param name="type">The type which has the property.</param> /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the property wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedProperty<TValue> GetProperty<TValue>(Type type, string name, bool required = true) { // get field from hierarchy - IReflectedProperty<TValue> property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + IReflectedProperty<TValue>? property = this.GetPropertyFromHierarchy<TValue>(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && property == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static property."); - return property; + return property!; } /**** @@ -98,8 +104,10 @@ namespace StardewModdingAPI.Framework.Reflection ****/ /// <summary>Get a instance method.</summary> /// <param name="obj">The object which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="name">The method name.</param> + /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedMethod GetMethod(object obj, string name, bool required = true) { // validate @@ -107,58 +115,25 @@ namespace StardewModdingAPI.Framework.Reflection throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object."); // get method from hierarchy - IReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + IReflectedMethod? method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); if (required && method == null) throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method."); - return method; + return method!; } /// <summary>Get a static method.</summary> /// <param name="type">The type which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="name">The method name.</param> + /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception> public IReflectedMethod GetMethod(Type type, string name, bool required = true) { // get method from hierarchy - IReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); + IReflectedMethod? method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static); if (required && method == null) throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method."); - return method; - } - - /**** - ** Methods by signature - ****/ - /// <summary>Get a instance method.</summary> - /// <param name="obj">The object which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="argumentTypes">The argument types of the method signature to find.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> - public IReflectedMethod GetMethod(object obj, string name, Type[] argumentTypes, bool required = true) - { - // validate parent - if (obj == null) - throw new ArgumentNullException(nameof(obj), "Can't get a instance method from a null object."); - - // get method from hierarchy - ReflectedMethod method = this.GetMethodFromHierarchy(obj.GetType(), obj, name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public, argumentTypes); - if (required && method == null) - throw new InvalidOperationException($"The {obj.GetType().FullName} object doesn't have a '{name}' instance method with that signature."); - return method; - } - - /// <summary>Get a static method.</summary> - /// <param name="type">The type which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="argumentTypes">The argument types of the method signature to find.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> - public IReflectedMethod GetMethod(Type type, string name, Type[] argumentTypes, bool required = true) - { - // get field from hierarchy - ReflectedMethod method = this.GetMethodFromHierarchy(type, null, name, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, argumentTypes); - if (required && method == null) - throw new InvalidOperationException($"The {type.FullName} object doesn't have a '{name}' static method with that signature."); - return method; + return method!; } @@ -168,18 +143,25 @@ namespace StardewModdingAPI.Framework.Reflection /// <summary>Get a field from the type hierarchy.</summary> /// <typeparam name="TValue">The expected field type.</typeparam> /// <param name="type">The type which has the field.</param> - /// <param name="obj">The object which has the field.</param> + /// <param name="obj">The object which has the field, or <c>null</c> for a static field.</param> /// <param name="name">The field name.</param> /// <param name="bindingFlags">The reflection binding which flags which indicates what type of field to find.</param> - private IReflectedField<TValue> GetFieldFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedField<TValue>? GetFieldFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); - FieldInfo field = this.GetCached<FieldInfo>($"field::{isStatic}::{type.FullName}::{name}", () => + FieldInfo? field = this.GetCached($"field::{isStatic}::{type.FullName}::{name}", () => { - FieldInfo fieldInfo = null; - for (; type != null && fieldInfo == null; type = type.BaseType) - fieldInfo = type.GetField(name, bindingFlags); - return fieldInfo; + for (Type? curType = type; curType != null; curType = curType.BaseType) + { + FieldInfo? fieldInfo = curType.GetField(name, bindingFlags); + if (fieldInfo != null) + { + type = curType; + return fieldInfo; + } + } + + return null; }); return field != null @@ -190,18 +172,25 @@ namespace StardewModdingAPI.Framework.Reflection /// <summary>Get a property from the type hierarchy.</summary> /// <typeparam name="TValue">The expected property type.</typeparam> /// <param name="type">The type which has the property.</param> - /// <param name="obj">The object which has the property.</param> + /// <param name="obj">The object which has the property, or <c>null</c> for a static property.</param> /// <param name="name">The property name.</param> /// <param name="bindingFlags">The reflection binding which flags which indicates what type of property to find.</param> - private IReflectedProperty<TValue> GetPropertyFromHierarchy<TValue>(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedProperty<TValue>? GetPropertyFromHierarchy<TValue>(Type type, object? obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); - PropertyInfo property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () => + PropertyInfo? property = this.GetCached<PropertyInfo>($"property::{isStatic}::{type.FullName}::{name}", () => { - PropertyInfo propertyInfo = null; - for (; type != null && propertyInfo == null; type = type.BaseType) - propertyInfo = type.GetProperty(name, bindingFlags); - return propertyInfo; + for (Type? curType = type; curType != null; curType = curType.BaseType) + { + PropertyInfo? propertyInfo = curType.GetProperty(name, bindingFlags); + if (propertyInfo != null) + { + type = curType; + return propertyInfo; + } + } + + return null; }); return property != null @@ -211,18 +200,25 @@ namespace StardewModdingAPI.Framework.Reflection /// <summary>Get a method from the type hierarchy.</summary> /// <param name="type">The type which has the method.</param> - /// <param name="obj">The object which has the method.</param> + /// <param name="obj">The object which has the method, or <c>null</c> for a static method.</param> /// <param name="name">The method name.</param> /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> - private IReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags) + private IReflectedMethod? GetMethodFromHierarchy(Type type, object? obj, string name, BindingFlags bindingFlags) { bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); - MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () => + MethodInfo? method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}", () => { - MethodInfo methodInfo = null; - for (; type != null && methodInfo == null; type = type.BaseType) - methodInfo = type.GetMethod(name, bindingFlags); - return methodInfo; + for (Type? curType = type; curType != null; curType = curType.BaseType) + { + MethodInfo? methodInfo = curType.GetMethod(name, bindingFlags); + if (methodInfo != null) + { + type = curType; + return methodInfo; + } + } + + return null; }); return method != null @@ -230,32 +226,12 @@ namespace StardewModdingAPI.Framework.Reflection : null; } - /// <summary>Get a method from the type hierarchy.</summary> - /// <param name="type">The type which has the method.</param> - /// <param name="obj">The object which has the method.</param> - /// <param name="name">The method name.</param> - /// <param name="bindingFlags">The reflection binding which flags which indicates what type of method to find.</param> - /// <param name="argumentTypes">The argument types of the method signature to find.</param> - private ReflectedMethod GetMethodFromHierarchy(Type type, object obj, string name, BindingFlags bindingFlags, Type[] argumentTypes) - { - bool isStatic = bindingFlags.HasFlag(BindingFlags.Static); - MethodInfo method = this.GetCached($"method::{isStatic}::{type.FullName}::{name}({string.Join(",", argumentTypes.Select(p => p.FullName))})", () => - { - MethodInfo methodInfo = null; - for (; type != null && methodInfo == null; type = type.BaseType) - methodInfo = type.GetMethod(name, bindingFlags, null, argumentTypes, null); - return methodInfo; - }); - return method != null - ? new ReflectedMethod(type, obj, method, isStatic) - : null; - } - /// <summary>Get a method or field through the cache.</summary> /// <typeparam name="TMemberInfo">The expected <see cref="MemberInfo"/> type.</typeparam> /// <param name="key">The cache key.</param> /// <param name="fetch">Fetches a new value to cache.</param> - private TMemberInfo GetCached<TMemberInfo>(string key, Func<TMemberInfo> fetch) where TMemberInfo : MemberInfo + private TMemberInfo? GetCached<TMemberInfo>(string key, Func<TMemberInfo?> fetch) + where TMemberInfo : MemberInfo { // get from cache if (this.Cache.Contains(key)) @@ -267,8 +243,8 @@ namespace StardewModdingAPI.Framework.Reflection } // fetch & cache new value - TMemberInfo result = fetch(); - CacheEntry cacheEntry = new CacheEntry(result != null, result); + TMemberInfo? result = fetch(); + CacheEntry cacheEntry = new(result); this.Cache.Add(key, cacheEntry, new CacheItemPolicy { SlidingExpiration = this.SlidingCacheExpiry }); return result; } diff --git a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs index 85e69ae6..37996b0f 100644 --- a/src/SMAPI/Framework/Rendering/SDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SDisplayDevice.cs @@ -25,7 +25,7 @@ namespace StardewModdingAPI.Framework.Rendering /// <param name="tile">The tile to draw.</param> /// <param name="location">The tile position to draw.</param> /// <param name="layerDepth">The layer depth at which to draw.</param> - public override void DrawTile(Tile tile, Location location, float layerDepth) + public override void DrawTile(Tile? tile, Location location, float layerDepth) { // identical to XnaDisplayDevice if (tile == null) @@ -56,7 +56,7 @@ namespace StardewModdingAPI.Framework.Rendering /// <param name="tile">The tile being drawn.</param> private SpriteEffects GetSpriteEffects(Tile tile) { - return tile.Properties.TryGetValue("@Flip", out PropertyValue propertyValue) && int.TryParse(propertyValue, out int value) + return tile.Properties.TryGetValue("@Flip", out PropertyValue? propertyValue) && int.TryParse(propertyValue, out int value) ? (SpriteEffects)value : SpriteEffects.None; } @@ -65,7 +65,7 @@ namespace StardewModdingAPI.Framework.Rendering /// <param name="tile">The tile being drawn.</param> private float GetRotation(Tile tile) { - if (!tile.Properties.TryGetValue("@Rotation", out PropertyValue propertyValue) || !int.TryParse(propertyValue, out int value)) + if (!tile.Properties.TryGetValue("@Rotation", out PropertyValue? propertyValue) || !int.TryParse(propertyValue, out int value)) return 0; value %= 360; diff --git a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs index cb499c6b..94b13378 100644 --- a/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs +++ b/src/SMAPI/Framework/Rendering/SXnaDisplayDevice.cs @@ -89,7 +89,7 @@ namespace StardewModdingAPI.Framework.Rendering /// <param name="tile">The tile to draw.</param> /// <param name="location">The tile position to draw.</param> /// <param name="layerDepth">The layer depth at which to draw.</param> - public virtual void DrawTile(Tile tile, Location location, float layerDepth) + public virtual void DrawTile(Tile? tile, Location location, float layerDepth) { if (tile == null) return; diff --git a/src/SMAPI/Framework/RequestExitDelegate.cs b/src/SMAPI/Framework/RequestExitDelegate.cs deleted file mode 100644 index 810c399b..00000000 --- a/src/SMAPI/Framework/RequestExitDelegate.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace StardewModdingAPI.Framework -{ - /// <summary>A delegate which requests that SMAPI immediately exit the game. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> - /// <param name="module">The module which requested an immediate exit.</param> - /// <param name="reason">The reason provided for the shutdown.</param> - internal delegate void RequestExitDelegate(string module, string reason); -} diff --git a/src/SMAPI/Framework/SChatBox.cs b/src/SMAPI/Framework/SChatBox.cs index e000d1cd..7d6f2e5f 100644 --- a/src/SMAPI/Framework/SChatBox.cs +++ b/src/SMAPI/Framework/SChatBox.cs @@ -3,7 +3,7 @@ using StardewValley.Menus; namespace StardewModdingAPI.Framework { - /// <summary>SMAPI's implementation of the chatbox which intercepts errors for logging.</summary> + /// <summary>SMAPI's implementation of the chat box which intercepts errors for logging.</summary> internal class SChatBox : ChatBox { /********* diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 55a7f083..e64318a5 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -19,6 +19,8 @@ using Newtonsoft.Json; using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Content; +using StardewModdingAPI.Framework.ContentManagers; +using StardewModdingAPI.Framework.Deprecations; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Input; @@ -43,7 +45,9 @@ using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Utilities; using StardewValley; +using StardewValley.Menus; using xTile.Display; +using LanguageCode = StardewValley.LocalizedContentManager.LanguageCode; using MiniMonoModHotfix = MonoMod.Utils.MiniMonoModHotfix; using PathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; using SObject = StardewValley.Object; @@ -60,7 +64,7 @@ namespace StardewModdingAPI.Framework ** Low-level components ****/ /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> - private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); + private readonly CancellationTokenSource CancellationToken = new(); /// <summary>Manages the SMAPI console window and log file.</summary> private readonly LogManager LogManager; @@ -69,16 +73,16 @@ namespace StardewModdingAPI.Framework private Monitor Monitor => this.LogManager.Monitor; /// <summary>Simplifies access to private game code.</summary> - private readonly Reflector Reflection = new Reflector(); + private readonly Reflector Reflection = new(); /// <summary>Encapsulates access to SMAPI core translations.</summary> - private readonly Translator Translator = new Translator(); + private readonly Translator Translator = new(); /// <summary>The SMAPI configuration settings.</summary> private readonly SConfig Settings; /// <summary>The mod toolkit used for generic mod interactions.</summary> - private readonly ModToolkit Toolkit = new ModToolkit(); + private readonly ModToolkit Toolkit = new(); /**** ** Higher-level components @@ -87,17 +91,17 @@ namespace StardewModdingAPI.Framework private readonly CommandManager CommandManager; /// <summary>The underlying game instance.</summary> - private SGameRunner Game; + private SGameRunner Game = null!; // initialized very early /// <summary>SMAPI's content manager.</summary> - private ContentCoordinator ContentCore; + private ContentCoordinator ContentCore = null!; // initialized very early /// <summary>The game's core multiplayer utility for the main player.</summary> - private SMultiplayer Multiplayer; + private SMultiplayer Multiplayer = null!; // initialized very early /// <summary>Tracks the installed mods.</summary> /// <remarks>This is initialized after the game starts.</remarks> - private readonly ModRegistry ModRegistry = new ModRegistry(); + private readonly ModRegistry ModRegistry = new(); /// <summary>Manages SMAPI events for mods.</summary> private readonly EventManager EventManager; @@ -121,37 +125,45 @@ namespace StardewModdingAPI.Framework /// <summary>Whether post-game-startup initialization has been performed.</summary> private bool IsInitialized; + /// <summary>Whether the game has initialized for any custom languages from <c>Data/AdditionalLanguages</c>.</summary> + private bool AreCustomLanguagesInitialized; + /// <summary>Whether the player just returned to the title screen.</summary> public bool JustReturnedToTitle { get; set; } + /// <summary>The last language set by the game.</summary> + private (string Locale, LanguageCode Code) LastLanguage { get; set; } = ("", LanguageCode.en); + /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from an update error.</summary> - private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second + private readonly Countdown UpdateCrashTimer = new(60); // 60 ticks = roughly one second /// <summary>Asset interceptors added or removed since the last tick.</summary> - private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>(); + private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new(); /// <summary>A list of queued commands to parse and execute.</summary> /// <remarks>This property must be thread-safe, since it's accessed from a separate console input thread.</remarks> - private readonly ConcurrentQueue<string> RawCommandQueue = new ConcurrentQueue<string>(); + private readonly ConcurrentQueue<string> RawCommandQueue = new(); /// <summary>A list of commands to execute on each screen.</summary> - private readonly PerScreen<List<Tuple<Command, string, string[]>>> ScreenCommandQueue = new PerScreen<List<Tuple<Command, string, string[]>>>(() => new List<Tuple<Command, string, string[]>>()); - + private readonly PerScreen<List<QueuedCommand>> ScreenCommandQueue = new(() => new List<QueuedCommand>()); /********* ** Accessors *********/ /// <summary>Manages deprecation warnings.</summary> /// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> - internal static DeprecationManager DeprecationManager { get; private set; } + internal static DeprecationManager DeprecationManager { get; private set; } = null!; // initialized in constructor, which happens before other code can access it /// <summary>The singleton instance.</summary> /// <remarks>This is only intended for use by external code like the Error Handler mod.</remarks> - internal static SCore Instance { get; private set; } + internal static SCore Instance { get; private set; } = null!; // initialized in constructor, which happens before other code can access it - /// <summary>The number of update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> + /// <summary>The number of game update ticks which have already executed. This is similar to <see cref="Game1.ticks"/>, but incremented more consistently for every tick.</summary> internal static uint TicksElapsed { get; private set; } + /// <summary>A specialized form of <see cref="TicksElapsed"/> which is incremented each time SMAPI performs a processing tick (whether that's a game update, one wait cycle while synchronizing code, etc).</summary> + internal static uint ProcessTicksElapsed { get; private set; } + /********* ** Public methods @@ -159,7 +171,8 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="modsPath">The path to search for mods.</param> /// <param name="writeToConsole">Whether to output log messages to the console.</param> - public SCore(string modsPath, bool writeToConsole) + /// <param name="developerMode">Whether to enable development features, or <c>null</c> to use the value from the settings file.</param> + public SCore(string modsPath, bool writeToConsole, bool? developerMode) { SCore.Instance = this; @@ -176,6 +189,8 @@ namespace StardewModdingAPI.Framework this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); if (File.Exists(Constants.ApiUserConfigPath)) JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings); + if (developerMode.HasValue) + this.Settings.OverrideDeveloperMode(developerMode.Value); this.LogManager = new LogManager(logPath: logPath, colorConfig: this.Settings.ConsoleColors, writeToConsole: writeToConsole, isVerbose: this.Settings.VerboseLogging, isDeveloperMode: this.Settings.DeveloperMode, getScreenIdForLog: this.GetScreenIdForLog); this.CommandManager = new CommandManager(this.Monitor); @@ -220,13 +235,13 @@ namespace StardewModdingAPI.Framework this.Toolkit.JsonHelper.JsonSettings.Converters.Add(converter); // add error handlers - AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); + AppDomain.CurrentDomain.UnhandledException += (_, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); // add more lenient assembly resolver - AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + AppDomain.CurrentDomain.AssemblyResolve += (_, e) => AssemblyLoader.ResolveAssembly(e.Name); // hook locale event - LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged(); + LocalizedContentManager.OnLanguageChange += _ => this.OnLocaleChanged(); // override game this.Multiplayer = new SMultiplayer(this.Monitor, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, this.Reflection, this.OnModMessageReceived, this.Settings.LogNetworkTraffic); @@ -315,6 +330,7 @@ namespace StardewModdingAPI.Framework } /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "May be disposed before SMAPI is fully initialized.")] public void Dispose() { // skip if already disposed @@ -339,9 +355,9 @@ namespace StardewModdingAPI.Framework // dispose core components this.IsGameRunning = false; this.ContentCore?.Dispose(); - this.CancellationToken?.Dispose(); + this.CancellationToken.Dispose(); this.Game?.Dispose(); - this.LogManager?.Dispose(); // dispose last to allow for any last-second log messages + this.LogManager.Dispose(); // dispose last to allow for any last-second log messages // end game (moved from Game1.OnExiting to let us clean up first) Process.GetCurrentProcess().Kill(); @@ -364,13 +380,13 @@ namespace StardewModdingAPI.Framework xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom)); // load mod data - ModToolkit toolkit = new ModToolkit(); + ModToolkit toolkit = new(); ModDatabase modDatabase = toolkit.GetModDatabase(Constants.ApiMetadataPath); // load mods { this.Monitor.Log("Loading mod metadata...", LogLevel.Debug); - ModResolver resolver = new ModResolver(); + ModResolver resolver = new(); // log loose files { @@ -501,12 +517,12 @@ namespace StardewModdingAPI.Framework /********* ** Parse commands *********/ - while (this.RawCommandQueue.TryDequeue(out string rawInput)) + while (this.RawCommandQueue.TryDequeue(out string? rawInput)) { // parse command - string name; - string[] args; - Command command; + string? name; + string[]? args; + Command? command; int screenId; try { @@ -523,7 +539,7 @@ namespace StardewModdingAPI.Framework } // queue command for screen - this.ScreenCommandQueue.GetValueForScreen(screenId).Add(Tuple.Create(command, name, args)); + this.ScreenCommandQueue.GetValueForScreen(screenId).Add(new(command, name, args)); } @@ -540,7 +556,7 @@ namespace StardewModdingAPI.Framework catch (Exception ex) { // log error - this.Monitor.Log($"An error occured in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"An error occurred in the overridden update loop: {ex.GetLogSummary()}", LogLevel.Error); // exit if irrecoverable if (!this.UpdateCrashTimer.Decrement()) @@ -549,6 +565,7 @@ namespace StardewModdingAPI.Framework finally { SCore.TicksElapsed++; + SCore.ProcessTicksElapsed++; } } @@ -558,7 +575,7 @@ namespace StardewModdingAPI.Framework /// <param name="runUpdate">Invoke the game's update logic.</param> private void OnPlayerInstanceUpdating(SGame instance, GameTime gameTime, Action runUpdate) { - var events = this.EventManager; + EventManager events = this.EventManager; try { @@ -567,7 +584,7 @@ namespace StardewModdingAPI.Framework *********/ if (this.JustReturnedToTitle) { - if (!(Game1.mapDisplayDevice is SDisplayDevice)) + if (Game1.mapDisplayDevice is not SDisplayDevice) Game1.mapDisplayDevice = this.GetMapDisplayDevice(); this.JustReturnedToTitle = false; @@ -578,12 +595,8 @@ namespace StardewModdingAPI.Framework *********/ { var commandQueue = this.ScreenCommandQueue.Value; - foreach (var entry in commandQueue) + foreach ((Command? command, string? name, string[]? args) in commandQueue) { - Command command = entry.Item1; - string name = entry.Item2; - string[] args = entry.Item3; - try { command.Callback.Invoke(name, args); @@ -620,8 +633,11 @@ namespace StardewModdingAPI.Framework { this.Monitor.Log("Game loader synchronizing..."); this.Reflection.GetMethod(Game1.game1, "UpdateTitleScreen").Invoke(Game1.currentGameTime); // run game logic to change music on load, etc + // ReSharper disable once ConstantConditionalAccessQualifier -- may become null within the loop while (Game1.currentLoader?.MoveNext() == true) { + SCore.ProcessTicksElapsed++; + // raise load stage changed switch (Game1.currentLoader.Current) { @@ -806,7 +822,7 @@ namespace StardewModdingAPI.Framework // raise cursor moved event if (state.Cursor.IsChanged) - events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New)); + events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old!, state.Cursor.New!)); // raise mouse wheel scrolled if (state.MouseWheelScroll.IsChanged) @@ -937,7 +953,7 @@ namespace StardewModdingAPI.Framework // raise player events if (raiseWorldEvents) { - PlayerSnapshot playerState = state.CurrentPlayer; + PlayerSnapshot playerState = state.CurrentPlayer!; // not null at this point Farmer player = playerState.Player; // raise current location changed @@ -946,25 +962,25 @@ namespace StardewModdingAPI.Framework if (this.Monitor.IsVerbose) this.Monitor.Log($"Context: set location to {playerState.Location.New}."); - events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New)); + events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old!, playerState.Location.New!)); } // raise player leveled up a skill - foreach (var pair in playerState.Skills) + foreach ((SkillType skill, var value) in playerState.Skills) { - if (!pair.Value.IsChanged) + if (!value.IsChanged) continue; if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}."); + this.Monitor.Log($"Events: player skill '{skill}' changed from {value.Old} to {value.New}."); - events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New)); + events.LevelChanged.Raise(new LevelChangedEventArgs(player, skill, value.Old, value.New)); } // raise player inventory changed if (playerState.Inventory.IsChanged) { - var inventory = playerState.Inventory; + SnapshotItemListDiff inventory = playerState.Inventory; if (this.Monitor.IsVerbose) this.Monitor.Log("Events: player inventory changed."); @@ -986,6 +1002,13 @@ namespace StardewModdingAPI.Framework // preloaded if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) this.OnLoadStageChanged(LoadStage.Loaded); + + // additional languages initialized + if (!this.AreCustomLanguagesInitialized && TitleMenu.ticksUntilLanguageLoad < 0) + { + this.AreCustomLanguagesInitialized = true; + this.ContentCore.OnAdditionalLanguagesInitialized(); + } } /********* @@ -1036,7 +1059,7 @@ namespace StardewModdingAPI.Framework // get locale string locale = this.ContentCore.GetLocale(); - LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + LanguageCode languageCode = this.ContentCore.Language; // update core translations this.Translator.SetLocale(locale, languageCode); @@ -1044,11 +1067,26 @@ namespace StardewModdingAPI.Framework // update mod translation helpers foreach (IModMetadata mod in this.ModRegistry.GetAll()) { - mod.Translations.SetLocale(locale, languageCode); + TranslationHelper translations = mod.Translations!; // not null at this point + translations.SetLocale(locale, languageCode); foreach (ContentPack contentPack in mod.GetFakeContentPacks()) contentPack.TranslationImpl.SetLocale(locale, languageCode); } + + // raise event + if (this.EventManager.LocaleChanged.HasListeners()) + { + this.EventManager.LocaleChanged.Raise( + new LocaleChangedEventArgs( + oldLanguage: this.LastLanguage.Code, + oldLocale: this.LastLanguage.Locale, + newLanguage: languageCode, + newLocale: locale + ) + ); + } + this.LastLanguage = (locale, languageCode); } /// <summary>Raised when the low-level stage while loading a save changes.</summary> @@ -1077,7 +1115,7 @@ namespace StardewModdingAPI.Framework break; case LoadStage.Loaded: - // override chatbox + // override chat box Game1.onScreenMenus.Remove(Game1.chatBox); Game1.onScreenMenus.Add(Game1.chatBox = new SChatBox(this.LogManager.MonitorForGame)); break; @@ -1095,6 +1133,78 @@ namespace StardewModdingAPI.Framework this.EventManager.DayEnding.RaiseEmpty(); } + /// <summary>A callback invoked after an asset is fully loaded through a content manager.</summary> + /// <param name="contentManager">The content manager through which the asset was loaded.</param> + /// <param name="assetName">The asset name that was loaded.</param> + private void OnAssetLoaded(IContentManager contentManager, IAssetName assetName) + { + if (this.EventManager.AssetReady.HasListeners()) + this.EventManager.AssetReady.Raise(new AssetReadyEventArgs(assetName, assetName.GetBaseAssetName())); + } + + /// <summary>A callback invoked after assets have been invalidated from the content cache.</summary> + /// <param name="assetNames">The invalidated asset names.</param> + private void OnAssetsInvalidated(IList<IAssetName> assetNames) + { + if (this.EventManager.AssetsInvalidated.HasListeners()) + this.EventManager.AssetsInvalidated.Raise(new AssetsInvalidatedEventArgs(assetNames, assetNames.Select(p => p.GetBaseAssetName()))); + } + + /// <summary>Get the load/edit operations to apply to an asset by querying registered <see cref="IContentEvents.AssetRequested"/> event handlers.</summary> + /// <param name="asset">The asset info being requested.</param> + private IList<AssetOperationGroup> RequestAssetOperations(IAssetInfo asset) + { + List<AssetOperationGroup> operations = new(); + + this.EventManager.AssetRequested.Raise( + invoke: (mod, invoke) => + { + AssetRequestedEventArgs args = new(mod, asset.Name, asset.NameWithoutLocale, asset.DataType, this.GetOnBehalfOfContentPack); + + invoke(args); + + if (args.LoadOperations.Any() || args.EditOperations.Any()) + { + operations.Add( + new AssetOperationGroup(mod, args.LoadOperations.ToArray(), args.EditOperations.ToArray()) + ); + } + } + ); + + return operations; + } + + /// <summary>Get the mod metadata for a content pack whose ID matches <paramref name="id"/>, if it's a valid content pack for the given <paramref name="mod"/>.</summary> + /// <param name="mod">The mod requesting to act on the content pack's behalf.</param> + /// <param name="id">The content pack ID.</param> + /// <param name="verb">The verb phrase indicating what action will be performed, like 'load assets' or 'edit assets'.</param> + /// <returns>Returns the content pack metadata if valid, else <c>null</c>.</returns> + private IModMetadata? GetOnBehalfOfContentPack(IModMetadata mod, string? id, string verb) + { + if (id == null) + return null; + + string errorPrefix = $"Can't {verb} on behalf of content pack ID '{id}'"; + + // get target mod + IModMetadata? onBehalfOf = this.ModRegistry.Get(id); + if (onBehalfOf == null) + { + mod.LogAsModOnce($"{errorPrefix}: there's no content pack installed with that ID.", LogLevel.Warn); + return null; + } + + // make sure it's a content pack for the requesting mod + if (!onBehalfOf.IsContentPack || !string.Equals(onBehalfOf.Manifest.ContentPackFor?.UniqueID, mod.Manifest.UniqueID)) + { + mod.LogAsModOnce($"{errorPrefix}: that isn't a content pack for this mod.", LogLevel.Warn); + return null; + } + + return onBehalfOf; + } + /// <summary>Raised immediately before the player returns to the title screen.</summary> private void OnReturningToTitle() { @@ -1120,7 +1230,7 @@ namespace StardewModdingAPI.Framework modIDs.Remove(message.FromModID); // don't send a broadcast back to the sender // raise events - this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); + this.EventManager.ModMessageReceived.Raise(new ModMessageReceivedEventArgs(message, this.Toolkit.JsonHelper), mod => modIDs.Contains(mod.Manifest.UniqueID)); } /// <summary>Constructor a content manager to read game content files.</summary> @@ -1129,9 +1239,22 @@ namespace StardewModdingAPI.Framework private LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { // Game1._temporaryContent initializing from SGame constructor + // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- this is the method that initializes it if (this.ContentCore == null) { - this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded, this.Settings.AggressiveMemoryOptimizations); + this.ContentCore = new ContentCoordinator( + serviceProvider: serviceProvider, + rootDirectory: rootDirectory, + currentCulture: Thread.CurrentThread.CurrentUICulture, + monitor: this.Monitor, + reflection: this.Reflection, + jsonHelper: this.Toolkit.JsonHelper, + onLoadingFirstAsset: this.InitializeBeforeFirstAssetLoaded, + onAssetLoaded: this.OnAssetLoaded, + onAssetsInvalidated: this.OnAssetsInvalidated, + aggressiveMemoryOptimizations: this.Settings.AggressiveMemoryOptimizations, + requestAssetOperations: this.RequestAssetOperations + ); if (this.ContentCore.Language != this.Translator.LocaleEnum) this.Translator.SetLocale(this.ContentCore.GetLocale(), this.ContentCore.Language); @@ -1169,21 +1292,21 @@ namespace StardewModdingAPI.Framework // detect issues bool hasObjectIssues = false; void LogIssue(int id, string issue) => this.Monitor.Log($@"Detected issue: item #{id} in Content\Data\ObjectInformation.xnb is invalid ({issue})."); - foreach (KeyValuePair<int, string> entry in Game1.objectInformation) + foreach ((int id, string? fieldsStr) in Game1.objectInformation) { // must not be empty - if (string.IsNullOrWhiteSpace(entry.Value)) + if (string.IsNullOrWhiteSpace(fieldsStr)) { - LogIssue(entry.Key, "entry is empty"); + LogIssue(id, "entry is empty"); hasObjectIssues = true; continue; } // require core fields - string[] fields = entry.Value.Split('/'); + string[] fields = fieldsStr.Split('/'); if (fields.Length < SObject.objectInfoDescriptionIndex + 1) { - LogIssue(entry.Key, "too few fields for an object"); + LogIssue(id, "too few fields for an object"); hasObjectIssues = true; continue; } @@ -1194,7 +1317,7 @@ namespace StardewModdingAPI.Framework case "Cooking": if (fields.Length < SObject.objectInfoBuffDurationIndex + 1) { - LogIssue(entry.Key, "too few fields for a cooking item"); + LogIssue(id, "too few fields for a cooking item"); hasObjectIssues = true; } break; @@ -1242,17 +1365,17 @@ namespace StardewModdingAPI.Framework string[] installedNames = registryKeys .SelectMany(registryKey => { - using RegistryKey key = Registry.LocalMachine.OpenSubKey(registryKey); + using RegistryKey? key = Registry.LocalMachine.OpenSubKey(registryKey); if (key == null) - return new string[0]; + return Array.Empty<string>(); return key .GetSubKeyNames() .Select(subkeyName => { - using RegistryKey subkey = key.OpenSubKey(subkeyName); - string displayName = (string)subkey?.GetValue("DisplayName"); - string displayVersion = (string)subkey?.GetValue("DisplayVersion"); + using RegistryKey? subkey = key.OpenSubKey(subkeyName); + string? displayName = (string?)subkey?.GetValue("DisplayName"); + string? displayVersion = (string?)subkey?.GetValue("DisplayVersion"); if (displayName != null && displayVersion != null && displayName.EndsWith($" {displayVersion}")) displayName = displayName.Substring(0, displayName.Length - displayVersion.Length - 1); @@ -1262,6 +1385,7 @@ namespace StardewModdingAPI.Framework .ToArray(); }) .Where(name => name != null && (name.Contains("MSI Afterburner") || name.Contains("RivaTuner"))) + .Select(name => name!) .Distinct() .OrderBy(name => name) .ToArray(); @@ -1289,19 +1413,19 @@ namespace StardewModdingAPI.Framework { // create client string url = this.Settings.WebApiBaseUrl; - WebApiClient client = new WebApiClient(url, Constants.ApiVersion); + WebApiClient client = new(url, Constants.ApiVersion); this.Monitor.Log("Checking for updates..."); // check SMAPI version { - ISemanticVersion updateFound = null; - string updateUrl = null; + ISemanticVersion? updateFound = null; + string? updateUrl = null; try { // fetch update check ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", Constants.ApiVersion, new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value; updateFound = response.SuggestedUpdate?.Version; - updateUrl = response.SuggestedUpdate?.Url ?? Constants.HomePageUrl; + updateUrl = response.SuggestedUpdate?.Url; // log message if (updateFound != null) @@ -1327,42 +1451,7 @@ namespace StardewModdingAPI.Framework // show update message on next launch if (updateFound != null) - this.LogManager.WriteUpdateMarker(updateFound.ToString(), updateUrl); - } - - // check Stardew64Installer version - if (Constants.IsPatchedByStardew64Installer(out ISemanticVersion patchedByVersion)) - { - ISemanticVersion updateFound = null; - string updateUrl = null; - try - { - // fetch update check - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Steviegt6.Stardew64Installer", patchedByVersion, new[] { $"GitHub:{this.Settings.Stardew64InstallerGitHubProjectName}" }) }, apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform).Single().Value; - updateFound = response.SuggestedUpdate?.Version; - updateUrl = response.SuggestedUpdate?.Url ?? Constants.HomePageUrl; - - // log message - if (updateFound != null) - this.Monitor.Log($"You can update Stardew64Installer to {updateFound}: {updateUrl}", LogLevel.Alert); - else - this.Monitor.Log(" Stardew64Installer okay."); - - // show errors - if (response.Errors.Any()) - { - this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}"); - } - } - catch (Exception ex) - { - this.Monitor.Log("Couldn't check for a new version of Stardew64Installer. This won't affect your game, but you won't be notified of new versions if this keeps happening.", LogLevel.Warn); - this.Monitor.Log(ex is WebException && ex.InnerException == null - ? $"Error: {ex.Message}" - : $"Error: {ex.GetLogSummary()}" - ); - } + this.LogManager.WriteUpdateMarker(updateFound.ToString(), updateUrl ?? Constants.HomePageUrl); } // check mod versions @@ -1396,12 +1485,12 @@ namespace StardewModdingAPI.Framework foreach (IModMetadata mod in mods.OrderBy(p => p.DisplayName)) { // link to update-check data - if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel result)) + if (!mod.HasID() || !results.TryGetValue(mod.Manifest.UniqueID, out ModEntryModel? result)) continue; mod.SetUpdateData(result); // handle errors - if (result.Errors != null && result.Errors.Any()) + if (result.Errors.Any()) { errors.AppendLine(result.Errors.Length == 1 ? $" {mod.DisplayName}: {result.Errors[0]}" @@ -1423,13 +1512,8 @@ namespace StardewModdingAPI.Framework { this.Monitor.Newline(); this.Monitor.Log($"You can update {updates.Count} mod{(updates.Count != 1 ? "s" : "")}:", LogLevel.Alert); - foreach (var entry in updates) - { - IModMetadata mod = entry.Item1; - ISemanticVersion newVersion = entry.Item2; - string newUrl = entry.Item3; + foreach ((IModMetadata mod, ISemanticVersion newVersion, string newUrl) in updates) this.Monitor.Log($" {mod.DisplayName} {newVersion}: {newUrl}", LogLevel.Alert); - } } else this.Monitor.Log(" All mods up to date."); @@ -1473,18 +1557,19 @@ namespace StardewModdingAPI.Framework // load mods IList<IModMetadata> skippedMods = new List<IModMetadata>(); - using (AssemblyLoader modAssemblyLoader = new AssemblyLoader(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods)) + using (AssemblyLoader modAssemblyLoader = new(Constants.Platform, this.Monitor, this.Settings.ParanoidWarnings, this.Settings.RewriteMods)) { // init HashSet<string> suppressUpdateChecks = new HashSet<string>(this.Settings.SuppressUpdateChecks, StringComparer.OrdinalIgnoreCase); - InterfaceProxyFactory proxyFactory = new InterfaceProxyFactory(); + IInterfaceProxyFactory proxyFactory = this.Settings.UsePintail + ? new InterfaceProxyFactory() + : new OriginalInterfaceProxyFactory(); // load mods foreach (IModMetadata mod in mods) { - if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string errorPhrase, out string errorDetails)) + if (!this.TryLoadMod(mod, mods, modAssemblyLoader, proxyFactory, jsonHelper, contentCore, modDatabase, suppressUpdateChecks, out ModFailReason? failReason, out string? errorPhrase, out string? errorDetails)) { - failReason ??= ModFailReason.LoadFailed; mod.SetStatus(ModMetadataStatus.Failed, failReason.Value, errorPhrase, errorDetails); skippedMods.Add(mod); } @@ -1506,27 +1591,51 @@ namespace StardewModdingAPI.Framework // initialize loaded non-content-pack mods this.Monitor.Log("Launching mods...", LogLevel.Debug); +#pragma warning disable CS0612, CS0618 // deprecated code foreach (IModMetadata metadata in loadedMods) { // add interceptors - if (metadata.Mod.Helper.Content is ContentHelper helper) + if (metadata.Mod?.Helper is ModHelper helper) { // ReSharper disable SuspiciousTypeConversion.Global if (metadata.Mod is IAssetEditor editor) + { + SCore.DeprecationManager.Warn( + source: metadata, + nounPhrase: $"{nameof(IAssetEditor)}", + version: "3.14.0", + severity: DeprecationLevel.Notice, + logStackTrace: false + ); + this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor)); + } + if (metadata.Mod is IAssetLoader loader) + { + SCore.DeprecationManager.Warn( + source: metadata, + nounPhrase: $"{nameof(IAssetLoader)}", + version: "3.14.0", + severity: DeprecationLevel.Notice, + logStackTrace: false + ); + this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader)); + } // ReSharper restore SuspiciousTypeConversion.Global - helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors); - helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders); + ContentHelper content = helper.GetLegacyContentHelper(); + content.ObservableAssetEditors.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors); + content.ObservableAssetLoaders.CollectionChanged += (_, e) => this.OnAssetInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders); } +#pragma warning restore CS0612, CS0618 // call entry method try { - IMod mod = metadata.Mod; - mod.Entry(mod.Helper); + IMod mod = metadata.Mod!; + mod.Entry(mod.Helper!); } catch (Exception ex) { @@ -1536,7 +1645,7 @@ namespace StardewModdingAPI.Framework // get mod API try { - object api = metadata.Mod.GetApi(); + object? api = metadata.Mod!.GetApi(); if (api != null && !api.GetType().IsPublic) { api = null; @@ -1565,15 +1674,16 @@ namespace StardewModdingAPI.Framework /// <param name="added">The interceptors that were added.</param> /// <param name="removed">The interceptors that were removed.</param> /// <param name="list">A list of interceptors to update for the change.</param> - private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list) + private void OnAssetInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T>? added, IEnumerable<T>? removed, IList<ModLinked<T>> list) + where T : notnull { - foreach (T interceptor in added ?? new T[0]) + foreach (T interceptor in added ?? Array.Empty<T>()) { this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: true)); list.Add(new ModLinked<T>(mod, interceptor)); } - foreach (T interceptor in removed ?? new T[0]) + foreach (T interceptor in removed ?? Array.Empty<T>()) { this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, interceptor, wasAdded: false)); foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray()) @@ -1594,7 +1704,7 @@ namespace StardewModdingAPI.Framework /// <param name="errorReasonPhrase">The user-facing reason phrase explaining why the mod couldn't be loaded (if applicable).</param> /// <param name="errorDetails">More detailed details about the error intended for developers (if any).</param> /// <returns>Returns whether the mod was successfully loaded.</returns> - private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, InterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, out ModFailReason? failReason, out string errorReasonPhrase, out string errorDetails) + private bool TryLoadMod(IModMetadata mod, IModMetadata[] mods, AssemblyLoader assemblyLoader, IInterfaceProxyFactory proxyFactory, JsonHelper jsonHelper, ContentCoordinator contentCore, ModDatabase modDatabase, HashSet<string> suppressUpdateChecks, [NotNullWhen(false)] out ModFailReason? failReason, out string? errorReasonPhrase, out string? errorDetails) { errorDetails = null; @@ -1603,6 +1713,7 @@ namespace StardewModdingAPI.Framework string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]..."); + // ReSharper disable once ConstantConditionalAccessQualifier -- mod may be invalid at this point else if (mod.Manifest?.EntryDll != null) this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})..."); // don't use Path.Combine here, since EntryDLL might not be valid else @@ -1610,21 +1721,22 @@ namespace StardewModdingAPI.Framework } // add warning for missing update key - if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest.UniqueID) && !mod.HasValidUpdateKeys()) + if (mod.HasID() && !suppressUpdateChecks.Contains(mod.Manifest!.UniqueID) && !mod.HasValidUpdateKeys()) mod.SetWarning(ModWarning.NoUpdateKeys); // validate status if (mod.Status == ModMetadataStatus.Failed) { this.Monitor.Log($" Failed: {mod.ErrorDetails ?? mod.Error}"); - failReason = mod.FailReason; + failReason = mod.FailReason ?? ModFailReason.LoadFailed; errorReasonPhrase = mod.Error; return false; } + IManifest manifest = mod.Manifest!; // validate dependencies // Although dependencies are validated before mods are loaded, a dependency may have failed to load. - foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) + foreach (IManifestDependency dependency in manifest.Dependencies.Where(p => p.IsRequired)) { if (this.ModRegistry.Get(dependency.UniqueID) == null) { @@ -1640,11 +1752,12 @@ namespace StardewModdingAPI.Framework // load as content pack if (mod.IsContentPack) { - IManifest manifest = mod.Manifest; IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); - IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); - TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); - IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); + CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath); + GameContentHelper gameContentHelper = new(this.ContentCore, mod, mod.DisplayName, monitor, this.Reflection); + IModContentHelper modContentHelper = new ModContentHelper(this.ContentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); + TranslationHelper translationHelper = new(mod, contentCore.GetLocale(), contentCore.Language); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, modContentHelper, translationHelper, jsonHelper, relativePathCache); mod.SetMod(contentPack, monitor, translationHelper); this.ModRegistry.Add(mod); @@ -1657,8 +1770,10 @@ namespace StardewModdingAPI.Framework else { // get mod info - IManifest manifest = mod.Manifest; - string assemblyPath = Path.Combine(mod.DirectoryPath, manifest.EntryDll); + string assemblyPath = Path.Combine( + mod.DirectoryPath, + CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath).GetFilePath(manifest.EntryDll!) + ); // load mod Assembly modAssembly; @@ -1669,7 +1784,7 @@ namespace StardewModdingAPI.Framework } catch (IncompatibleInstructionException) // details already in trace logs { - string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray(); + string[] updateUrls = new[] { modDatabase.GetModPageUrlFor(manifest.UniqueID), "https://smapi.io/mods" }.Where(p => p != null).ToArray()!; errorReasonPhrase = $"it's no longer compatible. Please check for a new version at {string.Join(" or ", updateUrls)}"; failReason = ModFailReason.Incompatible; return false; @@ -1695,7 +1810,7 @@ namespace StardewModdingAPI.Framework try { // get mod instance - if (!this.TryLoadModEntry(modAssembly, out Mod modEntry, out errorReasonPhrase)) + if (!this.TryLoadModEntry(modAssembly, out Mod? modEntry, out errorReasonPhrase)) { failReason = ModFailReason.LoadFailed; return false; @@ -1709,23 +1824,27 @@ namespace StardewModdingAPI.Framework return this.ModRegistry .GetAll(assemblyMods: false) - .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor.UniqueID)) - .Select(p => p.ContentPack) + .Where(p => p.IsContentPack && mod.HasID(p.Manifest.ContentPackFor!.UniqueID)) + .Select(p => p.ContentPack!) .ToArray(); } // init mod helpers IMonitor monitor = this.LogManager.GetMonitor(mod.DisplayName); - TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + TranslationHelper translationHelper = new(mod, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) { IMonitor packMonitor = this.LogManager.GetMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - TranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); - ContentPack contentPack = new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(packDirPath); + + GameContentHelper gameContentHelper = new(contentCore, mod, packManifest.Name, packMonitor, this.Reflection); + IModContentHelper packContentHelper = new ModContentHelper(contentCore, packDirPath, mod, packManifest.Name, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); + TranslationHelper packTranslationHelper = new(mod, contentCore.GetLocale(), contentCore.Language); + + ContentPack contentPack = new(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper, relativePathCache); this.ReloadTranslationsForTemporaryContentPack(mod, contentPack); mod.FakeContentPacks.Add(new WeakReference<ContentPack>(contentPack)); return contentPack; @@ -1733,14 +1852,19 @@ namespace StardewModdingAPI.Framework IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.CommandManager); - IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); - IContentPackHelper contentPackHelper = new ContentPackHelper(manifest.UniqueID, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack); - IDataHelper dataHelper = new DataHelper(manifest.UniqueID, mod.DirectoryPath, jsonHelper); - IReflectionHelper reflectionHelper = new ReflectionHelper(manifest.UniqueID, mod.DisplayName, this.Reflection); - IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); - IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.Multiplayer); - - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + CaseInsensitivePathLookup relativePathCache = CaseInsensitivePathLookup.GetCachedFor(mod.DirectoryPath); +#pragma warning disable CS0612 // deprecated code + ContentHelper contentHelper = new(contentCore, mod.DirectoryPath, mod, monitor, this.Reflection); +#pragma warning restore CS0612 + GameContentHelper gameContentHelper = new(contentCore, mod, mod.DisplayName, monitor, this.Reflection); + IModContentHelper modContentHelper = new ModContentHelper(contentCore, mod.DirectoryPath, mod, mod.DisplayName, gameContentHelper.GetUnderlyingContentManager(), relativePathCache, this.Reflection); + IContentPackHelper contentPackHelper = new ContentPackHelper(mod, new Lazy<IContentPack[]>(GetContentPacks), CreateFakeContentPack); + IDataHelper dataHelper = new DataHelper(mod, mod.DirectoryPath, jsonHelper); + IReflectionHelper reflectionHelper = new ReflectionHelper(mod, mod.DisplayName, this.Reflection); + IModRegistry modRegistryHelper = new ModRegistryHelper(mod, this.ModRegistry, proxyFactory, monitor); + IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(mod, this.Multiplayer); + + modHelper = new ModHelper(mod, mod.DirectoryPath, () => this.GetCurrentGameInstance().Input, events, contentHelper, gameContentHelper, modContentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod @@ -1768,7 +1892,7 @@ namespace StardewModdingAPI.Framework /// <param name="mod">The loaded instance.</param> /// <param name="error">The error indicating why loading failed (if applicable).</param> /// <returns>Returns whether the mod entry class was successfully loaded.</returns> - private bool TryLoadModEntry(Assembly modAssembly, out Mod mod, out string error) + private bool TryLoadModEntry(Assembly modAssembly, [NotNullWhen(true)] out Mod? mod, [NotNullWhen(false)] out string? error) { mod = null; @@ -1786,7 +1910,7 @@ namespace StardewModdingAPI.Framework } // get implementation - mod = (Mod)modAssembly.CreateInstance(modEntries[0].ToString()); + mod = (Mod?)modAssembly.CreateInstance(modEntries[0].ToString()); if (mod == null) { error = "its entry class couldn't be instantiated."; @@ -1832,7 +1956,7 @@ namespace StardewModdingAPI.Framework metadata.LogAsMod($" - {error}", LogLevel.Warn); } - metadata.Translations.SetTranslations(translations); + metadata.Translations!.SetTranslations(translations); } // fake content packs @@ -1867,7 +1991,7 @@ namespace StardewModdingAPI.Framework // read translation files var translations = new Dictionary<string, IDictionary<string, string>>(); errors = new List<string>(); - DirectoryInfo translationsDir = new DirectoryInfo(folderPath); + DirectoryInfo translationsDir = new(folderPath); if (translationsDir.Exists) { foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) @@ -1875,7 +1999,7 @@ namespace StardewModdingAPI.Framework string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); try { - if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data) || data == null) + if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string>? data)) { errors.Add($"{file.Name} file couldn't be read"); // mainly happens when the file is corrupted or empty continue; @@ -1894,8 +2018,8 @@ namespace StardewModdingAPI.Framework foreach (string locale in translations.Keys.ToArray()) { // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase); + HashSet<string> keys = new(StringComparer.OrdinalIgnoreCase); + HashSet<string> duplicateKeys = new(StringComparer.OrdinalIgnoreCase); foreach (string key in translations[locale].Keys.ToArray()) { if (!keys.Add(key)) @@ -1923,7 +2047,7 @@ namespace StardewModdingAPI.Framework { // default path { - FileInfo defaultFile = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); + FileInfo defaultFile = new(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.{Constants.LogExtension}")); if (!defaultFile.Exists) return defaultFile.FullName; } @@ -1931,7 +2055,7 @@ namespace StardewModdingAPI.Framework // get first disambiguated path for (int i = 2; i < int.MaxValue; i++) { - FileInfo file = new FileInfo(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); + FileInfo file = new(Path.Combine(Constants.LogDir, $"{Constants.LogFilename}.player-{i}.{Constants.LogExtension}")); if (!file.Exists) return file.FullName; } @@ -1943,7 +2067,7 @@ namespace StardewModdingAPI.Framework /// <summary>Delete normal (non-crash) log files created by SMAPI.</summary> private void PurgeNormalLogs() { - DirectoryInfo logsDir = new DirectoryInfo(Constants.LogDir); + DirectoryInfo logsDir = new(Constants.LogDir); if (!logsDir.Exists) return; @@ -1985,5 +2109,15 @@ namespace StardewModdingAPI.Framework return null; } + + + /********* + ** Private types + *********/ + /// <summary>A queued console command to run during the update loop.</summary> + /// <param name="Command">The command which can handle the input.</param> + /// <param name="Name">The parsed command name.</param> + /// <param name="Args">The parsed command arguments.</param> + private readonly record struct QueuedCommand(Command Command, string Name, string[] Args); } } diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 104cf330..0a8a068f 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -2,7 +2,6 @@ using System; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Text; -using System.Threading.Tasks; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Events; @@ -37,7 +36,7 @@ namespace StardewModdingAPI.Framework private readonly EventManager Events; /// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary> - private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second + private readonly Countdown DrawCrashTimer = new(60); // 60 ticks = roughly one second /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; @@ -46,10 +45,10 @@ namespace StardewModdingAPI.Framework private readonly Action<string> ExitGameImmediately; /// <summary>The initial override for <see cref="Input"/>. This value is null after initialization.</summary> - private SInputState InitialInput; + private SInputState? InitialInput; /// <summary>The initial override for <see cref="Multiplayer"/>. This value is null after initialization.</summary> - private SMultiplayer InitialMultiplayer; + private SMultiplayer? InitialMultiplayer; /// <summary>Raised when the instance is updating its state (roughly 60 times per second).</summary> private readonly Action<SGame, GameTime, Action> OnUpdating; @@ -64,21 +63,18 @@ namespace StardewModdingAPI.Framework /// <summary>Manages input visible to the game.</summary> public SInputState Input => (SInputState)Game1.input; - /// <summary>The game background task which initializes a new day.</summary> - public Task NewDayTask => Game1._newDayTask; - /// <summary>Monitors the entire game state for changes.</summary> - public WatcherCore Watchers { get; private set; } + public WatcherCore Watchers { get; private set; } = null!; // initialized on first update tick /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> - public WatcherSnapshot WatcherSnapshot { get; } = new WatcherSnapshot(); + public WatcherSnapshot WatcherSnapshot { get; } = new(); /// <summary>Whether the current update tick is the first one for this instance.</summary> public bool IsFirstTick = true; /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks> - public Countdown AfterLoadTimer { get; } = new Countdown(5); + public Countdown AfterLoadTimer { get; } = new(5); /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary> public bool IsBetweenSaveEvents { get; set; } @@ -92,7 +88,7 @@ namespace StardewModdingAPI.Framework /// <summary>Construct a content manager to read game content files.</summary> /// <remarks>This must be static because the game accesses it before the <see cref="SGame"/> constructor is called.</remarks> [NonInstancedStatic] - public static Func<IServiceProvider, string, LocalizedContentManager> CreateContentManagerImpl; + public static Func<IServiceProvider, string, LocalizedContentManager>? CreateContentManagerImpl; /********* @@ -136,11 +132,10 @@ namespace StardewModdingAPI.Framework /// <remarks>This is intended for use by <see cref="Keybind"/> and shouldn't be used directly in most cases.</remarks> internal static SButtonState GetInputState(SButton button) { - SInputState input = Game1.input as SInputState; - if (input == null) + if (Game1.input is not SInputState inputHandler) throw new InvalidOperationException("SMAPI's input state is not in a ready state yet."); - return input.GetState(button); + return inputHandler.GetState(button); } /// <inheritdoc /> @@ -170,13 +165,11 @@ namespace StardewModdingAPI.Framework { base.Initialize(); - // The game resets public static fields after the class is constructed (see - // GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here. + // The game resets public static fields after the class is constructed (see GameRunner.SetInstanceDefaults), so SMAPI needs to re-override them here. Game1.input = this.InitialInput; Game1.multiplayer = this.InitialMultiplayer; - // The Initial* fields should no longer be used after this point, since mods may - // further override them after initialization. + // The Initial* fields should no longer be used after this point, since mods may further override them after initialization. this.InitialInput = null; this.InitialMultiplayer = null; } @@ -249,6 +242,7 @@ namespace StardewModdingAPI.Framework Context.IsInDrawLoop = false; } +#nullable disable /// <summary>Replicate the game's draw logic with some changes for SMAPI.</summary> /// <param name="gameTime">A snapshot of the game timing state.</param> /// <param name="target_screen">The render target, if any.</param> @@ -256,6 +250,7 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantBaseQualifier", Justification = "copied from game code as-is")] @@ -263,8 +258,9 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "RedundantExplicitNullableCreation", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "IdentifierTypo", Justification = "copied from game code as-is")] - [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "MergeIntoPattern", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] + [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen) { var events = this.Events; @@ -624,7 +620,7 @@ namespace StardewModdingAPI.Framework if (tile != null) { Vector2 vector_draw_position = Game1.GlobalToLocal(Game1.viewport, tile_position * 64f); - Location draw_location = new Location((int)vector_draw_position.X, (int)vector_draw_position.Y); + Location draw_location = new((int)vector_draw_position.X, (int)vector_draw_position.Y); Game1.mapDisplayDevice.DrawTile(tile, draw_location, (tile_position.Y * 64f - 1f) / 10000f); } } @@ -950,5 +946,6 @@ namespace StardewModdingAPI.Framework this.drawOverlays(Game1.spriteBatch); Game1.PopUIMode(); } +#nullable enable } } diff --git a/src/SMAPI/Framework/SGameRunner.cs b/src/SMAPI/Framework/SGameRunner.cs index b816bb7c..213fe561 100644 --- a/src/SMAPI/Framework/SGameRunner.cs +++ b/src/SMAPI/Framework/SGameRunner.cs @@ -93,7 +93,7 @@ namespace StardewModdingAPI.Framework /// <param name="instanceIndex">The instance index.</param> public override Game1 CreateGameInstance(PlayerIndex playerIndex = PlayerIndex.One, int instanceIndex = 0) { - SInputState inputState = new SInputState(); + SInputState inputState = new(); return new SGame(playerIndex, instanceIndex, this.Monitor, this.Reflection, this.Events, inputState, this.ModHooks, this.Multiplayer, this.ExitGameImmediately, this.OnPlayerInstanceUpdating, this.OnGameContentLoaded); } @@ -148,7 +148,7 @@ namespace StardewModdingAPI.Framework /// <summary>Update metadata when a split screen is added or removed.</summary> private void UpdateForSplitScreenChanges() { - HashSet<int> oldScreenIds = new HashSet<int>(Context.ActiveScreenIds); + HashSet<int> oldScreenIds = new(Context.ActiveScreenIds); // track active screens Context.ActiveScreenIds.Clear(); diff --git a/src/SMAPI/Framework/SModHooks.cs b/src/SMAPI/Framework/SModHooks.cs index 101e022a..a7736c8b 100644 --- a/src/SMAPI/Framework/SModHooks.cs +++ b/src/SMAPI/Framework/SModHooks.cs @@ -33,7 +33,7 @@ namespace StardewModdingAPI.Framework /// <param name="action">The vanilla <see cref="Game1.newDayAfterFade"/> logic.</param> public override void OnGame1_NewDayAfterFade(Action action) { - this.BeforeNewDayAfterFade?.Invoke(); + this.BeforeNewDayAfterFade(); action(); } diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 5956b63f..e41e7edc 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -56,10 +56,10 @@ namespace StardewModdingAPI.Framework private readonly bool LogNetworkTraffic; /// <summary>The backing field for <see cref="Peers"/>.</summary> - private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new PerScreen<IDictionary<long, MultiplayerPeer>>(() => new Dictionary<long, MultiplayerPeer>()); + private readonly PerScreen<IDictionary<long, MultiplayerPeer>> PeersImpl = new(() => new Dictionary<long, MultiplayerPeer>()); /// <summary>The backing field for <see cref="HostPeer"/>.</summary> - private readonly PerScreen<MultiplayerPeer> HostPeerImpl = new PerScreen<MultiplayerPeer>(); + private readonly PerScreen<MultiplayerPeer?> HostPeerImpl = new(); /********* @@ -69,7 +69,7 @@ namespace StardewModdingAPI.Framework public IDictionary<long, MultiplayerPeer> Peers => this.PeersImpl.Value; /// <summary>The metadata for the host player, if the current player is a farmhand.</summary> - public MultiplayerPeer HostPeer + public MultiplayerPeer? HostPeer { get => this.HostPeerImpl.Value; private set => this.HostPeerImpl.Value = value; @@ -111,20 +111,20 @@ namespace StardewModdingAPI.Framework { switch (client) { - case LidgrenClient _: + case LidgrenClient: { - string address = this.Reflection.GetField<string>(client, "address").GetValue(); + string address = this.Reflection.GetField<string?>(client, "address").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: no valid address found."); return new SLidgrenClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); } - case GalaxyNetClient _: + case GalaxyNetClient: { - GalaxyID address = this.Reflection.GetField<GalaxyID>(client, "lobbyId").GetValue(); + GalaxyID address = this.Reflection.GetField<GalaxyID?>(client, "lobbyId").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: no valid address found."); return new SGalaxyNetClient(address, this.OnClientProcessingMessage, this.OnClientSendingMessage); } default: - this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}", LogLevel.Trace); + this.Monitor.Log($"Unknown multiplayer client type: {client.GetType().AssemblyQualifiedName}"); return client; } } @@ -135,20 +135,20 @@ namespace StardewModdingAPI.Framework { switch (server) { - case LidgrenServer _: + case LidgrenServer: { - IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + IGameServer gameServer = this.Reflection.GetField<IGameServer?>(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize base networking client: the required 'gameServer' field wasn't found."); return new SLidgrenServer(gameServer, this, this.OnServerProcessingMessage); } - case GalaxyNetServer _: + case GalaxyNetServer: { - IGameServer gameServer = this.Reflection.GetField<IGameServer>(server, "gameServer").GetValue(); + IGameServer gameServer = this.Reflection.GetField<IGameServer?>(server, "gameServer").GetValue() ?? throw new InvalidOperationException("Can't initialize GOG networking client: the required 'gameServer' field wasn't found."); return new SGalaxyNetServer(gameServer, this, this.OnServerProcessingMessage); } default: - this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}", LogLevel.Trace); + this.Monitor.Log($"Unknown multiplayer server type: {server.GetType().AssemblyQualifiedName}"); return server; } } @@ -160,7 +160,7 @@ namespace StardewModdingAPI.Framework protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { if (this.LogNetworkTraffic) - this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}"); switch (message.MessageType) { @@ -184,7 +184,7 @@ namespace StardewModdingAPI.Framework public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { if (this.LogNetworkTraffic) - this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}"); switch (message.MessageType) { @@ -192,11 +192,11 @@ namespace StardewModdingAPI.Framework case (byte)MessageType.ModContext: { // parse message - RemoteContextModel model = this.ReadContext(message.Reader); - this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + RemoteContextModel? model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for farmhand {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}."); // store peer - MultiplayerPeer newPeer = new MultiplayerPeer( + MultiplayerPeer newPeer = new( playerID: message.FarmerID, screenID: this.GetScreenId(message.FarmerID), model: model, @@ -243,8 +243,8 @@ namespace StardewModdingAPI.Framework // store peer if new if (!this.Peers.ContainsKey(message.FarmerID)) { - this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}.", LogLevel.Trace); - MultiplayerPeer peer = new MultiplayerPeer( + this.Monitor.Log($"Received connection for vanilla player {message.FarmerID}."); + MultiplayerPeer peer = new( playerID: message.FarmerID, screenID: this.GetScreenId(message.FarmerID), model: null, @@ -280,7 +280,7 @@ namespace StardewModdingAPI.Framework public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { if (this.LogNetworkTraffic) - this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); + this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}"); switch (message.MessageType) { @@ -288,11 +288,11 @@ namespace StardewModdingAPI.Framework case (byte)MessageType.ModContext: { // parse message - RemoteContextModel model = this.ReadContext(message.Reader); - this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}.", LogLevel.Trace); + RemoteContextModel? model = this.ReadContext(message.Reader); + this.Monitor.Log($"Received context for {(model?.IsHost == true ? "host" : "farmhand")} {message.FarmerID} running {(model != null ? $"SMAPI {model.ApiVersion} with {model.Mods.Length} mods" : "vanilla")}."); // store peer - MultiplayerPeer peer = new MultiplayerPeer( + MultiplayerPeer peer = new( playerID: message.FarmerID, screenID: this.GetScreenId(message.FarmerID), model: model, @@ -314,7 +314,7 @@ namespace StardewModdingAPI.Framework // store peer if (!this.Peers.ContainsKey(message.FarmerID) && this.HostPeer == null) { - this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}.", LogLevel.Trace); + this.Monitor.Log($"Received connection for vanilla host {message.FarmerID}."); var peer = new MultiplayerPeer( playerID: message.FarmerID, screenID: this.GetScreenId(message.FarmerID), @@ -332,7 +332,7 @@ namespace StardewModdingAPI.Framework case (byte)MessageType.PlayerIntroduction: { // store peer - if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer peer)) + if (!this.Peers.TryGetValue(message.FarmerID, out MultiplayerPeer? peer)) { peer = new MultiplayerPeer( playerID: message.FarmerID, @@ -341,7 +341,7 @@ namespace StardewModdingAPI.Framework sendMessage: sendMessage, isHost: this.HostPeer == null ); - this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}.", LogLevel.Trace); + this.Monitor.Log($"Received connection for vanilla {(peer.IsHost ? "host" : "farmhand")} {message.FarmerID}."); this.AddPeer(peer, canBeHost: true); } @@ -365,9 +365,9 @@ namespace StardewModdingAPI.Framework { foreach (long playerID in this.disconnectingFarmers) { - if (this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + if (this.Peers.TryGetValue(playerID, out MultiplayerPeer? peer)) { - this.Monitor.Log($"Player quit: {playerID}", LogLevel.Trace); + this.Monitor.Log($"Player quit: {playerID}"); this.Peers.Remove(playerID); this.EventManager.PeerDisconnected.Raise(new PeerDisconnectedEventArgs(peer)); } @@ -382,7 +382,7 @@ namespace StardewModdingAPI.Framework /// <param name="fromModID">The unique ID of the mod sending the message.</param> /// <param name="toModIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> /// <param name="toPlayerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> - public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[] toModIDs, long[] toPlayerIDs) + public void BroadcastModMessage<TMessage>(TMessage message, string messageType, string fromModID, string[]? toModIDs, long[]? toPlayerIDs) { // validate input if (message == null) @@ -420,7 +420,7 @@ namespace StardewModdingAPI.Framework } // get data to send - ModMessageModel model = new ModMessageModel( + ModMessageModel model = new( fromPlayerID: Game1.player.UniqueMultiplayerID, fromModID: fromModID, toModIDs: toModIDs, @@ -434,7 +434,7 @@ namespace StardewModdingAPI.Framework if (sendToSelf) { if (this.LogNetworkTraffic) - this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}.", LogLevel.Trace); + this.Monitor.Log($"Broadcasting '{messageType}' message to self: {data}."); this.OnModMessageReceived(model); } @@ -447,7 +447,7 @@ namespace StardewModdingAPI.Framework foreach (MultiplayerPeer peer in sendToPeers) { if (this.LogNetworkTraffic) - this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}.", LogLevel.Trace); + this.Monitor.Log($"Broadcasting '{messageType}' message to farmhand {peer.PlayerID}: {data}."); peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, data)); } @@ -455,7 +455,7 @@ namespace StardewModdingAPI.Framework else if (this.HostPeer?.HasSmapi == true) { if (this.LogNetworkTraffic) - this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}.", LogLevel.Trace); + this.Monitor.Log($"Broadcasting '{messageType}' message to host {this.HostPeer.PlayerID}: {data}."); this.HostPeer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, this.HostPeer.PlayerID, data)); } @@ -486,13 +486,13 @@ namespace StardewModdingAPI.Framework /// <summary>Read the metadata context for a player.</summary> /// <param name="reader">The stream reader.</param> - private RemoteContextModel ReadContext(BinaryReader reader) + private RemoteContextModel? ReadContext(BinaryReader reader) { string data = reader.ReadString(); RemoteContextModel model = this.JsonHelper.Deserialize<RemoteContextModel>(data); return model.ApiVersion != null ? model - : null; // no data available for unmodded players + : null; // no data available for vanilla players } /// <summary>Receive a mod message sent from another player's mods.</summary> @@ -504,7 +504,7 @@ namespace StardewModdingAPI.Framework ModMessageModel model = this.JsonHelper.Deserialize<ModMessageModel>(json); HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs()); if (this.LogNetworkTraffic) - this.Monitor.Log($"Received message: {json}.", LogLevel.Trace); + this.Monitor.Log($"Received message: {json}."); // notify local mods if (playerIDs.Contains(Game1.player.UniqueMultiplayerID)) @@ -513,12 +513,15 @@ namespace StardewModdingAPI.Framework // forward to other players if (Context.IsMainPlayer && playerIDs.Any(p => p != Game1.player.UniqueMultiplayerID)) { - ModMessageModel newModel = new ModMessageModel(model); foreach (long playerID in playerIDs) { - if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer peer)) + if (playerID != Game1.player.UniqueMultiplayerID && playerID != model.FromPlayerID && this.Peers.TryGetValue(playerID, out MultiplayerPeer? peer)) { - newModel.ToPlayerIDs = new[] { peer.PlayerID }; + ModMessageModel newModel = new(model) + { + ToPlayerIDs = new[] { peer.PlayerID } + }; + this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialize(newModel, Formatting.None))); } @@ -544,22 +547,20 @@ namespace StardewModdingAPI.Framework /// <summary>Get the fields to include in a context sync message sent to other players.</summary> private object[] GetContextSyncMessageFields() { - RemoteContextModel model = new RemoteContextModel - { - IsHost = Context.IsWorldReady && Context.IsMainPlayer, - Platform = Constants.TargetPlatform, - ApiVersion = Constants.ApiVersion, - GameVersion = Constants.GameVersion, - Mods = this.ModRegistry + RemoteContextModel model = new( + isHost: Context.IsWorldReady && Context.IsMainPlayer, + platform: Constants.TargetPlatform, + apiVersion: Constants.ApiVersion, + gameVersion: Constants.GameVersion, + mods: this.ModRegistry .GetAll() - .Select(mod => new RemoteContextModModel - { - ID = mod.Manifest.UniqueID, - Name = mod.Manifest.Name, - Version = mod.Manifest.Version - }) + .Select(mod => new RemoteContextModModel( + id: mod.Manifest.UniqueID, + name: mod.Manifest.Name, + version: mod.Manifest.Version + )) .ToArray() - }; + ); return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } @@ -571,21 +572,19 @@ namespace StardewModdingAPI.Framework if (!peer.HasSmapi) return new object[] { "{}" }; - RemoteContextModel model = new RemoteContextModel - { - IsHost = peer.IsHost, - Platform = peer.Platform.Value, - ApiVersion = peer.ApiVersion, - GameVersion = peer.GameVersion, - Mods = peer.Mods - .Select(mod => new RemoteContextModModel - { - ID = mod.ID, - Name = mod.Name, - Version = mod.Version - }) + RemoteContextModel model = new( + isHost: peer.IsHost, + platform: peer.Platform.Value, + apiVersion: peer.ApiVersion, + gameVersion: peer.GameVersion, + mods: peer.Mods + .Select(mod => new RemoteContextModModel( + id: mod.ID, + name: mod.Name, + version: mod.Version + )) .ToArray() - }; + ); return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } diff --git a/src/SMAPI/Framework/Serialization/KeybindConverter.cs b/src/SMAPI/Framework/Serialization/KeybindConverter.cs index 93a274a8..539f1291 100644 --- a/src/SMAPI/Framework/Serialization/KeybindConverter.cs +++ b/src/SMAPI/Framework/Serialization/KeybindConverter.cs @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework.Serialization /// <param name="objectType">The object type.</param> /// <param name="existingValue">The object being read.</param> /// <param name="serializer">The calling serializer.</param> - public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { string path = reader.Path; @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework.Serialization { case JsonToken.Null: return objectType == typeof(Keybind) - ? (object)new Keybind() + ? new Keybind() : new KeybindList(); case JsonToken.String: @@ -53,13 +53,13 @@ namespace StardewModdingAPI.Framework.Serialization if (objectType == typeof(Keybind)) { - return Keybind.TryParse(str, out Keybind parsed, out string[] errors) + return Keybind.TryParse(str, out Keybind? parsed, out string[] errors) ? parsed : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); } else { - return KeybindList.TryParse(str, out KeybindList parsed, out string[] errors) + return KeybindList.TryParse(str, out KeybindList? parsed, out string[] errors) ? parsed : throw new SParseException($"Can't parse {nameof(KeybindList)} from invalid value '{str}' (path: {path}).\n{string.Join("\n", errors)}"); } @@ -74,7 +74,7 @@ namespace StardewModdingAPI.Framework.Serialization /// <param name="writer">The JSON writer.</param> /// <param name="value">The value.</param> /// <param name="serializer">The calling serializer.</param> - public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { writer.WriteValue(value?.ToString()); } diff --git a/src/SMAPI/Framework/Singleton.cs b/src/SMAPI/Framework/Singleton.cs index 399a8bf0..1bf318c4 100644 --- a/src/SMAPI/Framework/Singleton.cs +++ b/src/SMAPI/Framework/Singleton.cs @@ -5,6 +5,6 @@ namespace StardewModdingAPI.Framework internal static class Singleton<T> where T : new() { /// <summary>The singleton instance.</summary> - public static T Instance { get; } = new T(); + public static T Instance { get; } = new(); } } diff --git a/src/SMAPI/Framework/SnapshotDiff.cs b/src/SMAPI/Framework/SnapshotDiff.cs index 5b6288ff..d659d2b4 100644 --- a/src/SMAPI/Framework/SnapshotDiff.cs +++ b/src/SMAPI/Framework/SnapshotDiff.cs @@ -13,10 +13,10 @@ namespace StardewModdingAPI.Framework public bool IsChanged { get; private set; } /// <summary>The previous value.</summary> - public T Old { get; private set; } + public T? Old { get; private set; } /// <summary>The current value.</summary> - public T New { get; private set; } + public T? New { get; private set; } /********* diff --git a/src/SMAPI/Framework/SnapshotItemListDiff.cs b/src/SMAPI/Framework/SnapshotItemListDiff.cs index e8ab1b1e..76060db2 100644 --- a/src/SMAPI/Framework/SnapshotItemListDiff.cs +++ b/src/SMAPI/Framework/SnapshotItemListDiff.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Events; using StardewValley; @@ -46,7 +47,7 @@ namespace StardewModdingAPI.Framework /// <param name="stackSizes">The items with their previous stack sizes.</param> /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param> /// <returns>Returns whether anything changed.</returns> - public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, out SnapshotItemListDiff changes) + public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, [NotNullWhen(true)] out SnapshotItemListDiff? changes) { KeyValuePair<Item, int>[] sizesChanged = stackSizes.Where(p => p.Key.Stack != p.Value).ToArray(); if (sizesChanged.Any() || added.Any() || removed.Any()) diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs index 2d0efa0d..90066af1 100644 --- a/src/SMAPI/Framework/SnapshotListDiff.cs +++ b/src/SMAPI/Framework/SnapshotListDiff.cs @@ -11,10 +11,10 @@ namespace StardewModdingAPI.Framework ** Fields *********/ /// <summary>The removed values.</summary> - private readonly List<T> RemovedImpl = new List<T>(); + private readonly List<T> RemovedImpl = new(); /// <summary>The added values.</summary> - private readonly List<T> AddedImpl = new List<T>(); + private readonly List<T> AddedImpl = new(); /********* @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework /// <param name="isChanged">Whether the value changed since the last update.</param> /// <param name="removed">The removed values.</param> /// <param name="added">The added values.</param> - public void Update(bool isChanged, IEnumerable<T> removed, IEnumerable<T> added) + public void Update(bool isChanged, IEnumerable<T>? removed, IEnumerable<T>? added) { this.IsChanged = isChanged; diff --git a/src/SMAPI/Framework/StateTracking/ChestTracker.cs b/src/SMAPI/Framework/StateTracking/ChestTracker.cs index 65f58ee7..c33a7498 100644 --- a/src/SMAPI/Framework/StateTracking/ChestTracker.cs +++ b/src/SMAPI/Framework/StateTracking/ChestTracker.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Framework.StateTracking.Comparers; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; @@ -18,10 +19,10 @@ namespace StardewModdingAPI.Framework.StateTracking private readonly IDictionary<Item, int> StackSizes; /// <summary>Items added since the last update.</summary> - private readonly HashSet<Item> Added = new HashSet<Item>(new ObjectReferenceComparer<Item>()); + private readonly HashSet<Item> Added = new(new ObjectReferenceComparer<Item>()); /// <summary>Items removed since the last update.</summary> - private readonly HashSet<Item> Removed = new HashSet<Item>(new ObjectReferenceComparer<Item>()); + private readonly HashSet<Item> Removed = new(new ObjectReferenceComparer<Item>()); /// <summary>The underlying inventory watcher.</summary> private readonly ICollectionWatcher<Item> InventoryWatcher; @@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>Get the inventory changes since the last update, if anything changed.</summary> /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param> /// <returns>Returns whether anything changed.</returns> - public bool TryGetInventoryChanges(out SnapshotItemListDiff changes) + public bool TryGetInventoryChanges([NotNullWhen(true)] out SnapshotItemListDiff? changes) { return SnapshotItemListDiff.TryGetChanges(added: this.Added, removed: this.Removed, stackSizes: this.StackSizes, out changes); } diff --git a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs index a96ffdb6..9d8559b4 100644 --- a/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs +++ b/src/SMAPI/Framework/StateTracking/Comparers/EquatableComparer.cs @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers /// <returns>true if the specified objects are equal; otherwise, false.</returns> /// <param name="x">The first object to compare.</param> /// <param name="y">The second object to compare.</param> - public bool Equals(T x, T y) + public bool Equals(T? x, T? y) { if (x == null) return y == null; diff --git a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs index cc1d6553..41b17e10 100644 --- a/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs +++ b/src/SMAPI/Framework/StateTracking/Comparers/GenericEqualsComparer.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers /// <returns>true if the specified objects are equal; otherwise, false.</returns> /// <param name="x">The first object to compare.</param> /// <param name="y">The second object to compare.</param> - public bool Equals(T x, T y) + public bool Equals(T? x, T? y) { if (x == null) return y == null; diff --git a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs index ef9adafb..e6ece854 100644 --- a/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs +++ b/src/SMAPI/Framework/StateTracking/Comparers/ObjectReferenceComparer.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Comparers /// <returns>true if the specified objects are equal; otherwise, false.</returns> /// <param name="x">The first object to compare.</param> /// <param name="y">The second object to compare.</param> - public bool Equals(T x, T y) + public bool Equals(T? x, T? y) { return object.ReferenceEquals(x, y); } diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs index 32ec8c7e..256370ce 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs @@ -17,10 +17,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers private HashSet<TValue> LastValues; /// <summary>The pairs added since the last reset.</summary> - private readonly List<TValue> AddedImpl = new List<TValue>(); + private readonly List<TValue> AddedImpl = new(); /// <summary>The pairs removed since the last reset.</summary> - private readonly List<TValue> RemovedImpl = new List<TValue>(); + private readonly List<TValue> RemovedImpl = new(); /********* diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs index 5ca4b9f4..5f76fe0a 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableWatcher.cs @@ -40,7 +40,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { this.GetValue = getValue; this.Comparer = comparer; - this.PreviousValue = getValue(); + this.CurrentValue = getValue(); + this.PreviousValue = this.CurrentValue; } /// <summary>Update the current value if needed.</summary> diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs index 30e6274f..84340fbf 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers @@ -10,16 +11,16 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers ** Accessors *********/ /// <summary>A singleton collection watcher instance.</summary> - public static ImmutableCollectionWatcher<TValue> Instance { get; } = new ImmutableCollectionWatcher<TValue>(); + public static ImmutableCollectionWatcher<TValue> Instance { get; } = new(); /// <summary>Whether the collection changed since the last reset.</summary> public bool IsChanged { get; } = false; /// <summary>The values added since the last reset.</summary> - public IEnumerable<TValue> Added { get; } = new TValue[0]; + public IEnumerable<TValue> Added { get; } = Array.Empty<TValue>(); /// <summary>The values removed since the last reset.</summary> - public IEnumerable<TValue> Removed { get; } = new TValue[0]; + public IEnumerable<TValue> Removed { get; } = Array.Empty<TValue>(); /********* diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs index 21e84c47..676c9fb4 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetCollectionWatcher.cs @@ -15,10 +15,10 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers private readonly NetCollection<TValue> Field; /// <summary>The pairs added since the last reset.</summary> - private readonly List<TValue> AddedImpl = new List<TValue>(); + private readonly List<TValue> AddedImpl = new(); /// <summary>The pairs removed since the last reset.</summary> - private readonly List<TValue> RemovedImpl = new List<TValue>(); + private readonly List<TValue> RemovedImpl = new(); /********* diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs index e6882f7e..f55e4cea 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetDictionaryWatcher.cs @@ -10,6 +10,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <typeparam name="TSerialDict">The serializable dictionary type that can store the keys and values.</typeparam> /// <typeparam name="TSelf">The net field instance type.</typeparam> internal class NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> : BaseDisposableWatcher, IDictionaryWatcher<TKey, TValue> + where TKey : notnull where TField : class, INetObject<INetSerializable>, new() where TSerialDict : IDictionary<TKey, TValue>, new() where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs index c29d2783..97aedca8 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs @@ -16,13 +16,13 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers private readonly ObservableCollection<TValue> Field; /// <summary>The pairs added since the last reset.</summary> - private readonly List<TValue> AddedImpl = new List<TValue>(); + private readonly List<TValue> AddedImpl = new(); /// <summary>The pairs removed since the last reset.</summary> - private readonly List<TValue> RemovedImpl = new List<TValue>(); + private readonly List<TValue> RemovedImpl = new(); /// <summary>The previous values as of the last update.</summary> - private readonly List<TValue> PreviousValues = new List<TValue>(); + private readonly List<TValue> PreviousValues = new(); /********* @@ -79,7 +79,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>A callback invoked when an entry is added or removed from the collection.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + private void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) { if (e.Action == NotifyCollectionChangedAction.Reset) { @@ -88,8 +88,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers } else { - TValue[] added = e.NewItems?.Cast<TValue>().ToArray(); - TValue[] removed = e.OldItems?.Cast<TValue>().ToArray(); + TValue[]? added = e.NewItems?.Cast<TValue>().ToArray(); + TValue[]? removed = e.OldItems?.Cast<TValue>().ToArray(); if (removed != null) { diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index bde43486..c4a4d0b9 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -18,7 +18,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher which compares values using their <see cref="object.Equals(object)"/> method. This method should only be used when <see cref="ForEquatable{T}"/> won't work, since this doesn't validate whether they're comparable.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static IValueWatcher<T> ForGenericEquality<T>(Func<T> getValue) where T : struct + public static IValueWatcher<T> ForGenericEquality<T>(Func<T> getValue) + where T : struct { return new ComparableWatcher<T>(getValue, new GenericEqualsComparer<T>()); } @@ -26,7 +27,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for an <see cref="IEquatable{T}"/> value.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static IValueWatcher<T> ForEquatable<T>(Func<T> getValue) where T : IEquatable<T> + public static IValueWatcher<T> ForEquatable<T>(Func<T> getValue) + where T : IEquatable<T> { return new ComparableWatcher<T>(getValue, new EquatableComparer<T>()); } @@ -77,7 +79,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for a net collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net collection.</param> - public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> + public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) + where T : class, INetObject<INetSerializable> { return new NetCollectionWatcher<T>(collection); } @@ -85,7 +88,8 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for a net list.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net list.</param> - public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) where T : class, INetObject<INetSerializable> + public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) + where T : class, INetObject<INetSerializable> { return new NetListWatcher<T>(collection); } @@ -98,6 +102,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <typeparam name="TSelf">The net field instance type.</typeparam> /// <param name="field">The net field.</param> public static NetDictionaryWatcher<TKey, TValue, TField, TSerialDict, TSelf> ForNetDictionary<TKey, TValue, TField, TSerialDict, TSelf>(NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> field) + where TKey : notnull where TField : class, INetObject<INetSerializable>, new() where TSerialDict : IDictionary<TKey, TValue>, new() where TSelf : NetDictionary<TKey, TValue, TField, TSerialDict, TSelf> diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 6d3a62bb..ff72a19b 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; @@ -18,7 +19,7 @@ namespace StardewModdingAPI.Framework.StateTracking ** Fields *********/ /// <summary>The underlying watchers.</summary> - private readonly List<IWatcher> Watchers = new List<IWatcher>(); + private readonly List<IWatcher> Watchers = new(); /********* @@ -84,7 +85,7 @@ namespace StardewModdingAPI.Framework.StateTracking this.FurnitureWatcher }); - this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]); + this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: Array.Empty<KeyValuePair<Vector2, SObject>>()); } /// <summary>Update the current value if needed.</summary> @@ -129,20 +130,20 @@ namespace StardewModdingAPI.Framework.StateTracking private void UpdateChestWatcherList(IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed) { // remove unused watchers - foreach (KeyValuePair<Vector2, SObject> pair in removed) + foreach ((Vector2 tile, SObject? obj) in removed) { - if (pair.Value is Chest && this.ChestWatchers.TryGetValue(pair.Key, out ChestTracker watcher)) + if (obj is Chest && this.ChestWatchers.TryGetValue(tile, out ChestTracker? watcher)) { watcher.Dispose(); - this.ChestWatchers.Remove(pair.Key); + this.ChestWatchers.Remove(tile); } } // add new watchers - foreach (KeyValuePair<Vector2, SObject> pair in added) + foreach ((Vector2 tile, SObject? obj) in added) { - if (pair.Value is Chest chest && !this.ChestWatchers.ContainsKey(pair.Key)) - this.ChestWatchers.Add(pair.Key, new ChestTracker(chest)); + if (obj is Chest chest && !this.ChestWatchers.ContainsKey(tile)) + this.ChestWatchers.Add(tile, new ChestTracker(chest)); } } } diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs index cf49a7c1..5433ac8e 100644 --- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs +++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework.StateTracking.Comparers; @@ -21,10 +22,10 @@ namespace StardewModdingAPI.Framework.StateTracking private IDictionary<Item, int> CurrentInventory; /// <summary>The player's last valid location.</summary> - private GameLocation LastValidLocation; + private GameLocation? LastValidLocation; /// <summary>The underlying watchers.</summary> - private readonly List<IWatcher> Watchers = new List<IWatcher>(); + private readonly List<IWatcher> Watchers = new(); /********* @@ -34,7 +35,7 @@ namespace StardewModdingAPI.Framework.StateTracking public Farmer Player { get; } /// <summary>The player's current location.</summary> - public IValueWatcher<GameLocation> LocationWatcher { get; } + public IValueWatcher<GameLocation?> LocationWatcher { get; } /// <summary>Tracks changes to the player's skill levels.</summary> public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; } @@ -49,7 +50,8 @@ namespace StardewModdingAPI.Framework.StateTracking { // init player data this.Player = player; - this.PreviousInventory = this.GetInventory(); + this.CurrentInventory = this.GetInventory(); + this.PreviousInventory = new Dictionary<Item, int>(this.CurrentInventory); // init trackers this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation); @@ -93,7 +95,7 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>Get the player's current location, ignoring temporary null values.</summary> /// <remarks>The game will set <see cref="Character.currentLocation"/> to null in some cases, e.g. when they're a secondary player in multiplayer and transition to a location that hasn't been synced yet. While that's happening, this returns the player's last valid location instead.</remarks> - public GameLocation GetCurrentLocation() + public GameLocation? GetCurrentLocation() { return this.Player.currentLocation ?? this.LastValidLocation; } @@ -101,7 +103,7 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>Get the inventory changes since the last update, if anything changed.</summary> /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param> /// <returns>Returns whether anything changed.</returns> - public bool TryGetInventoryChanges(out SnapshotItemListDiff changes) + public bool TryGetInventoryChanges([NotNullWhen(true)] out SnapshotItemListDiff? changes) { IDictionary<Item, int> current = this.GetInventory(); @@ -122,7 +124,7 @@ namespace StardewModdingAPI.Framework.StateTracking public void Dispose() { this.PreviousInventory.Clear(); - this.CurrentInventory?.Clear(); + this.CurrentInventory.Clear(); foreach (IWatcher watcher in this.Watchers) watcher.Dispose(); diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs index 6c9cc4f5..0d0469d7 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs @@ -17,25 +17,25 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots public GameLocation Location { get; } /// <summary>Tracks added or removed buildings.</summary> - public SnapshotListDiff<Building> Buildings { get; } = new SnapshotListDiff<Building>(); + public SnapshotListDiff<Building> Buildings { get; } = new(); /// <summary>Tracks added or removed debris.</summary> - public SnapshotListDiff<Debris> Debris { get; } = new SnapshotListDiff<Debris>(); + public SnapshotListDiff<Debris> Debris { get; } = new(); /// <summary>Tracks added or removed large terrain features.</summary> - public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new SnapshotListDiff<LargeTerrainFeature>(); + public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new(); /// <summary>Tracks added or removed NPCs.</summary> - public SnapshotListDiff<NPC> Npcs { get; } = new SnapshotListDiff<NPC>(); + public SnapshotListDiff<NPC> Npcs { get; } = new(); /// <summary>Tracks added or removed objects.</summary> - public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new SnapshotListDiff<KeyValuePair<Vector2, Object>>(); + public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new(); /// <summary>Tracks added or removed terrain features.</summary> - public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>(); + public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new(); /// <summary>Tracks added or removed furniture.</summary> - public SnapshotListDiff<Furniture> Furniture { get; } = new SnapshotListDiff<Furniture>(); + public SnapshotListDiff<Furniture> Furniture { get; } = new(); /// <summary>Tracks changed chest inventories.</summary> public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>(); @@ -68,7 +68,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots this.ChestItems.Clear(); foreach (ChestTracker tracker in watcher.ChestWatchers.Values) { - if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff changes)) + if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff? changes)) this.ChestItems[tracker.Chest] = changes; } } diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs index 0908b02a..6a24ec30 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots ** Fields *********/ /// <summary>An empty item list diff.</summary> - private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]); + private readonly SnapshotItemListDiff EmptyItemListDiff = new(Array.Empty<Item>(), Array.Empty<Item>(), Array.Empty<ItemStackSizeChange>()); /********* @@ -24,14 +24,14 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots public Farmer Player { get; } /// <summary>The player's current location.</summary> - public SnapshotDiff<GameLocation> Location { get; } = new SnapshotDiff<GameLocation>(); + public SnapshotDiff<GameLocation> Location { get; } = new(); /// <summary>Tracks changes to the player's skill levels.</summary> public IDictionary<SkillType, SnapshotDiff<int>> Skills { get; } = Enum .GetValues(typeof(SkillType)) .Cast<SkillType>() - .ToDictionary(skill => skill, skill => new SnapshotDiff<int>()); + .ToDictionary(skill => skill, _ => new SnapshotDiff<int>()); /// <summary>Get a list of inventory changes.</summary> public SnapshotItemListDiff Inventory { get; private set; } @@ -45,17 +45,18 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots public PlayerSnapshot(Farmer player) { this.Player = player; + this.Inventory = this.EmptyItemListDiff; } /// <summary>Update the tracked values.</summary> /// <param name="watcher">The player watcher to snapshot.</param> public void Update(PlayerTracker watcher) { - this.Location.Update(watcher.LocationWatcher); - foreach (var pair in this.Skills) - pair.Value.Update(watcher.SkillWatchers[pair.Key]); + this.Location.Update(watcher.LocationWatcher!); + foreach ((SkillType skill, var value) in this.Skills) + value.Update(watcher.SkillWatchers[skill]); - this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges) + this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff? itemChanges) ? itemChanges : this.EmptyItemListDiff; } diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs index cf51e040..27a891de 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs @@ -11,31 +11,31 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots ** Accessors *********/ /// <summary>Tracks changes to the window size.</summary> - public SnapshotDiff<Point> WindowSize { get; } = new SnapshotDiff<Point>(); + public SnapshotDiff<Point> WindowSize { get; } = new(); /// <summary>Tracks changes to the current player.</summary> - public PlayerSnapshot CurrentPlayer { get; private set; } + public PlayerSnapshot? CurrentPlayer { get; private set; } /// <summary>Tracks changes to the time of day (in 24-hour military format).</summary> - public SnapshotDiff<int> Time { get; } = new SnapshotDiff<int>(); + public SnapshotDiff<int> Time { get; } = new(); /// <summary>Tracks changes to the save ID.</summary> - public SnapshotDiff<ulong> SaveID { get; } = new SnapshotDiff<ulong>(); + public SnapshotDiff<ulong> SaveID { get; } = new(); /// <summary>Tracks changes to the game's locations.</summary> - public WorldLocationsSnapshot Locations { get; } = new WorldLocationsSnapshot(); + public WorldLocationsSnapshot Locations { get; } = new(); /// <summary>Tracks changes to <see cref="Game1.activeClickableMenu"/>.</summary> - public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new SnapshotDiff<IClickableMenu>(); + public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new(); /// <summary>Tracks changes to the cursor position.</summary> - public SnapshotDiff<ICursorPosition> Cursor { get; } = new SnapshotDiff<ICursorPosition>(); + public SnapshotDiff<ICursorPosition> Cursor { get; } = new(); /// <summary>Tracks changes to the mouse wheel scroll.</summary> - public SnapshotDiff<int> MouseWheelScroll { get; } = new SnapshotDiff<int>(); + public SnapshotDiff<int> MouseWheelScroll { get; } = new(); /// <summary>Tracks changes to the content locale.</summary> - public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new SnapshotDiff<LocalizedContentManager.LanguageCode>(); + public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new(); /********* @@ -54,7 +54,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots // update snapshots this.WindowSize.Update(watchers.WindowSizeWatcher); this.Locale.Update(watchers.LocaleWatcher); - this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker); + this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker!); this.Time.Update(watchers.TimeWatcher); this.SaveID.Update(watchers.SaveIdWatcher); this.Locations.Update(watchers.LocationsWatcher); diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs index 73ed2d8f..59f94942 100644 --- a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs @@ -12,14 +12,14 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots ** Fields *********/ /// <summary>A map of tracked locations.</summary> - private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new Dictionary<GameLocation, LocationSnapshot>(new ObjectReferenceComparer<GameLocation>()); + private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new(new ObjectReferenceComparer<GameLocation>()); /********* ** Accessors *********/ /// <summary>Tracks changes to the location list.</summary> - public SnapshotListDiff<GameLocation> LocationList { get; } = new SnapshotListDiff<GameLocation>(); + public SnapshotListDiff<GameLocation> LocationList { get; } = new(); /// <summary>The tracked locations.</summary> public IEnumerable<LocationSnapshot> Locations => this.LocationsDict.Values; @@ -42,7 +42,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots // update locations foreach (LocationTracker locationWatcher in watcher.Locations) { - if (!this.LocationsDict.TryGetValue(locationWatcher.Location, out LocationSnapshot snapshot)) + if (!this.LocationsDict.TryGetValue(locationWatcher.Location, out LocationSnapshot? snapshot)) this.LocationsDict[locationWatcher.Location] = snapshot = new LocationSnapshot(locationWatcher.Location); snapshot.Update(locationWatcher); diff --git a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs index e968d79c..817a6011 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -25,10 +25,10 @@ namespace StardewModdingAPI.Framework.StateTracking private readonly ICollectionWatcher<GameLocation> VolcanoLocationListWatcher; /// <summary>A lookup of the tracked locations.</summary> - private IDictionary<GameLocation, LocationTracker> LocationDict { get; } = new Dictionary<GameLocation, LocationTracker>(new ObjectReferenceComparer<GameLocation>()); + private Dictionary<GameLocation, LocationTracker> LocationDict { get; } = new(new ObjectReferenceComparer<GameLocation>()); /// <summary>A lookup of registered buildings and their indoor location.</summary> - private readonly IDictionary<Building, GameLocation> BuildingIndoors = new Dictionary<Building, GameLocation>(new ObjectReferenceComparer<Building>()); + private readonly Dictionary<Building, GameLocation?> BuildingIndoors = new(new ObjectReferenceComparer<Building>()); /********* @@ -99,10 +99,9 @@ namespace StardewModdingAPI.Framework.StateTracking } // detect building interiors changed (e.g. construction completed) - foreach (KeyValuePair<Building, GameLocation> pair in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value))) + foreach ((Building building, GameLocation? oldIndoors) in this.BuildingIndoors.Where(p => !object.Equals(p.Key.indoors.Value, p.Value))) { - GameLocation oldIndoors = pair.Value; - GameLocation newIndoors = pair.Key.indoors.Value; + GameLocation? newIndoors = building.indoors.Value; if (oldIndoors != null) this.Added.Add(oldIndoors); @@ -187,19 +186,19 @@ namespace StardewModdingAPI.Framework.StateTracking ****/ /// <summary>Add the given building.</summary> /// <param name="building">The building to add.</param> - public void Add(Building building) + public void Add(Building? building) { if (building == null) return; - GameLocation indoors = building.indoors.Value; + GameLocation? indoors = building.indoors.Value; this.BuildingIndoors[building] = indoors; this.Add(indoors); } /// <summary>Add the given location.</summary> /// <param name="location">The location to add.</param> - public void Add(GameLocation location) + public void Add(GameLocation? location) { if (location == null) return; @@ -218,7 +217,7 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>Remove the given building.</summary> /// <param name="building">The building to remove.</param> - public void Remove(Building building) + public void Remove(Building? building) { if (building == null) return; @@ -229,12 +228,12 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>Remove the given location.</summary> /// <param name="location">The location to remove.</param> - public void Remove(GameLocation location) + public void Remove(GameLocation? location) { if (location == null) return; - if (this.LocationDict.TryGetValue(location, out LocationTracker watcher)) + if (this.LocationDict.TryGetValue(location, out LocationTracker? watcher)) { // track change this.Removed.Add(location); diff --git a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs index 9d63ab2c..b5fc1f57 100644 --- a/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs +++ b/src/SMAPI/Framework/TemporaryHacks/MiniMonoModHotfix.cs @@ -5,15 +5,15 @@ // // This will be removed when Harmony/MonoMod are updated to incorporate the fix. // -// Special thanks to 0x0ade for submitting this worokaround! Copy/pasted and adapted from MonoMod. +// Special thanks to 0x0ade for submitting this workaround! Copy/pasted and adapted from MonoMod. using System; -using System.Reflection; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Reflection.Emit; using System.Runtime.CompilerServices; using HarmonyLib; -using System.Reflection.Emit; // ReSharper disable once CheckNamespace -- Temporary hotfix submitted by the MonoMod author. namespace MonoMod.Utils @@ -24,38 +24,38 @@ namespace MonoMod.Utils { // .NET Framework can break member ordering if using Module.Resolve* on certain members. - private static object[] _NoArgs = new object[0]; - private static object[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null }; + private static readonly object[] _NoArgs = Array.Empty<object>(); + private static readonly object?[] _CacheGetterArgs = { /* MemberListType.All */ 0, /* name apparently always null? */ null }; - private static Type t_RuntimeModule = + private static readonly Type? t_RuntimeModule = typeof(Module).Assembly .GetType("System.Reflection.RuntimeModule"); - private static PropertyInfo p_RuntimeModule_RuntimeType = + private static readonly PropertyInfo? p_RuntimeModule_RuntimeType = typeof(Module).Assembly .GetType("System.Reflection.RuntimeModule") ?.GetProperty("RuntimeType", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - private static Type t_RuntimeType = + private static readonly Type? t_RuntimeType = typeof(Type).Assembly .GetType("System.RuntimeType"); - private static PropertyInfo p_RuntimeType_Cache = + private static readonly PropertyInfo? p_RuntimeType_Cache = typeof(Type).Assembly .GetType("System.RuntimeType") ?.GetProperty("Cache", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - private static MethodInfo m_RuntimeTypeCache_GetFieldList = + private static readonly MethodInfo? m_RuntimeTypeCache_GetFieldList = typeof(Type).Assembly .GetType("System.RuntimeType+RuntimeTypeCache") ?.GetMethod("GetFieldList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - private static MethodInfo m_RuntimeTypeCache_GetPropertyList = + private static readonly MethodInfo? m_RuntimeTypeCache_GetPropertyList = typeof(Type).Assembly .GetType("System.RuntimeType+RuntimeTypeCache") ?.GetMethod("GetPropertyList", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); - private static readonly ConditionalWeakTable<Type, CacheFixEntry> _CacheFixed = new ConditionalWeakTable<Type, CacheFixEntry>(); + private static readonly ConditionalWeakTable<Type, CacheFixEntry> _CacheFixed = new(); public static void Apply() { @@ -63,37 +63,37 @@ namespace MonoMod.Utils harmony.Patch( original: typeof(Harmony).Assembly - .GetType("HarmonyLib.MethodBodyReader") + .GetType("HarmonyLib.MethodBodyReader", throwOnError: true)! .GetMethod("ReadOperand", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix)) ); harmony.Patch( original: typeof(MonoMod.Utils.ReflectionHelper).Assembly - .GetType("MonoMod.Utils.DynamicMethodDefinition+<>c__DisplayClass3_0") + .GetType("MonoMod.Utils.DynamicMethodDefinition+<>c__DisplayClass3_0", throwOnError: true)! .GetMethod("<_CopyMethodToDefinition>g__ResolveTokenAs|1", BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance), transpiler: new HarmonyMethod(typeof(MiniMonoModHotfix), nameof(ResolveTokenFix)) ); } - private static IEnumerable<CodeInstruction> ResolveTokenFix(IEnumerable<CodeInstruction> instrs) + private static IEnumerable<CodeInstruction> ResolveTokenFix(IEnumerable<CodeInstruction> instructions) { - MethodInfo getdecl = typeof(MiniMonoModHotfix).GetMethod(nameof(GetRealDeclaringType)); - MethodInfo fixup = typeof(MiniMonoModHotfix).GetMethod(nameof(FixReflectionCache)); + MethodInfo getRealDeclaringType = typeof(MiniMonoModHotfix).GetMethod(nameof(MiniMonoModHotfix.GetRealDeclaringType)) ?? throw new InvalidOperationException($"Can't get required method {nameof(MiniMonoModHotfix)}.{nameof(GetRealDeclaringType)}"); + MethodInfo fixReflectionCache = typeof(MiniMonoModHotfix).GetMethod(nameof(MiniMonoModHotfix.FixReflectionCache)) ?? throw new InvalidOperationException($"Can't get required method {nameof(MiniMonoModHotfix)}.{nameof(FixReflectionCache)}"); - foreach (CodeInstruction instr in instrs) + foreach (CodeInstruction instruction in instructions) { - yield return instr; + yield return instruction; - if (instr.operand is MethodInfo called) + if (instruction.operand is MethodInfo called) { switch (called.Name) { case "ResolveType": // type.FixReflectionCache(); yield return new CodeInstruction(OpCodes.Dup); - yield return new CodeInstruction(OpCodes.Call, fixup); + yield return new CodeInstruction(OpCodes.Call, fixReflectionCache); break; case "ResolveMember": @@ -101,15 +101,15 @@ namespace MonoMod.Utils case "ResolveField": // member.GetRealDeclaringType().FixReflectionCache(); yield return new CodeInstruction(OpCodes.Dup); - yield return new CodeInstruction(OpCodes.Call, getdecl); - yield return new CodeInstruction(OpCodes.Call, fixup); + yield return new CodeInstruction(OpCodes.Call, getRealDeclaringType); + yield return new CodeInstruction(OpCodes.Call, fixReflectionCache); break; } } } } - public static Type GetModuleType(this Module module) + public static Type? GetModuleType(this Module? module) { // Sadly we can't blindly resolve type 0x02000001 as the runtime throws ArgumentException. @@ -118,22 +118,21 @@ namespace MonoMod.Utils // .NET if (p_RuntimeModule_RuntimeType != null) - return (Type)p_RuntimeModule_RuntimeType.GetValue(module, _NoArgs); + return (Type?)p_RuntimeModule_RuntimeType.GetValue(module, _NoArgs); // The hotfix doesn't apply to Mono anyway, thus that's not copied over. return null; } - public static Type GetRealDeclaringType(this MemberInfo member) - => member.DeclaringType ?? member.Module?.GetModuleType(); + public static Type? GetRealDeclaringType(this MemberInfo member) + { + return member.DeclaringType ?? member.Module.GetModuleType(); + } - public static void FixReflectionCache(this Type type) + public static void FixReflectionCache(this Type? type) { - if (t_RuntimeType == null || - p_RuntimeType_Cache == null || - m_RuntimeTypeCache_GetFieldList == null || - m_RuntimeTypeCache_GetPropertyList == null) + if (t_RuntimeType == null || p_RuntimeType_Cache == null || m_RuntimeTypeCache_GetFieldList == null || m_RuntimeTypeCache_GetPropertyList == null) return; for (; type != null; type = type.DeclaringType) @@ -143,21 +142,17 @@ namespace MonoMod.Utils if (!t_RuntimeType.IsInstanceOfType(type)) continue; - CacheFixEntry entry = _CacheFixed.GetValue(type, rt => { - CacheFixEntry entryNew = new CacheFixEntry(); - object cache; - Array properties, fields; - + CacheFixEntry entry = _CacheFixed.GetValue(type, rt => + { // All RuntimeTypes MUST have a cache, the getter is non-virtual, it creates on demand and asserts non-null. - entryNew.Cache = cache = p_RuntimeType_Cache.GetValue(rt, _NoArgs); - entryNew.Properties = properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList); - entryNew.Fields = fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + object cache = MiniMonoModHotfix.p_RuntimeType_Cache.GetValue(rt, MiniMonoModHotfix._NoArgs)!; + Array properties = MiniMonoModHotfix._GetArray(cache, MiniMonoModHotfix.m_RuntimeTypeCache_GetPropertyList); + Array fields = MiniMonoModHotfix._GetArray(cache, MiniMonoModHotfix.m_RuntimeTypeCache_GetFieldList); _FixReflectionCacheOrder<PropertyInfo>(properties); _FixReflectionCacheOrder<FieldInfo>(fields); - entryNew.NeedsVerify = false; - return entryNew; + return new CacheFixEntry(cache, properties, fields, needsVerify: false); }); if (entry.NeedsVerify && !_Verify(entry, type)) @@ -175,44 +170,43 @@ namespace MonoMod.Utils private static bool _Verify(CacheFixEntry entry, Type type) { - object cache; - Array properties, fields; - // The cache can sometimes be invalidated. // TODO: Figure out if only the arrays get replaced or if the entire cache object gets replaced! - if (entry.Cache != (cache = p_RuntimeType_Cache.GetValue(type, _NoArgs))) + object cache = p_RuntimeType_Cache!.GetValue(type, _NoArgs)!; + if (entry.Cache != cache) { entry.Cache = cache; - entry.Properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList); - entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + entry.Properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList!); + entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!); return false; } - else if (entry.Properties != (properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList))) + + Array properties = _GetArray(cache, m_RuntimeTypeCache_GetPropertyList!); + if (entry.Properties != properties) { entry.Properties = properties; - entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList); + entry.Fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!); return false; - } - else if (entry.Fields != (fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList))) + + Array fields = _GetArray(cache, m_RuntimeTypeCache_GetFieldList!); + if (entry.Fields != fields) { entry.Fields = fields; return false; } - else - { - // Cache should still be the same, no re-fix necessary. - return true; - } + + // Cache should still be the same, no re-fix necessary. + return true; } private static Array _GetArray(object cache, MethodInfo getter) { // Get and discard once, otherwise we might not be getting the actual backing array. getter.Invoke(cache, _CacheGetterArgs); - return (Array)getter.Invoke(cache, _CacheGetterArgs); + return (Array)getter.Invoke(cache, _CacheGetterArgs)!; } private static void _FixReflectionCacheOrder<T>(Array orig) where T : MemberInfo @@ -220,7 +214,7 @@ namespace MonoMod.Utils // Sort using a short-lived list. List<T> list = new List<T>(orig.Length); for (int i = 0; i < orig.Length; i++) - list.Add((T)orig.GetValue(i)); + list.Add((T)orig.GetValue(i)!); list.Sort((a, b) => a.MetadataToken - b.MetadataToken); @@ -230,10 +224,18 @@ namespace MonoMod.Utils private class CacheFixEntry { - public object Cache; + public object? Cache; public Array Properties; public Array Fields; public bool NeedsVerify; + + public CacheFixEntry(object? cache, Array properties, Array fields, bool needsVerify) + { + this.Cache = cache; + this.Properties = properties; + this.Fields = fields; + this.NeedsVerify = needsVerify; + } } } } diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs index 4492b17f..3beee250 100644 --- a/src/SMAPI/Framework/Translator.cs +++ b/src/SMAPI/Framework/Translator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewValley; @@ -21,7 +22,7 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The current locale.</summary> + /// <summary>The current locale code like <c>fr-FR</c>, or an empty string for English.</summary> public string Locale { get; private set; } /// <summary>The game's current language code.</summary> @@ -37,9 +38,10 @@ namespace StardewModdingAPI.Framework this.SetLocale(string.Empty, LocalizedContentManager.LanguageCode.en); } - /// <summary>Set the current locale and precache translations.</summary> + /// <summary>Set the current locale and pre-cache translations.</summary> /// <param name="locale">The current locale.</param> /// <param name="localeEnum">The game's current language code.</param> + [MemberNotNull(nameof(Translator.ForLocale), nameof(Translator.Locale))] public void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) { this.Locale = locale.ToLower().Trim(); @@ -48,8 +50,9 @@ namespace StardewModdingAPI.Framework this.ForLocale = new Dictionary<string, Translation>(StringComparer.OrdinalIgnoreCase); foreach (string key in this.GetAllKeysRaw()) { - string text = this.GetRaw(key, locale, withFallback: true); - this.ForLocale.Add(key, new Translation(this.Locale, key, text)); + string? text = this.GetRaw(key, locale, withFallback: true); + if (text != null) + this.ForLocale.Add(key, new Translation(this.Locale, key, text)); } } @@ -63,14 +66,14 @@ namespace StardewModdingAPI.Framework /// <param name="key">The translation key.</param> public Translation Get(string key) { - this.ForLocale.TryGetValue(key, out Translation translation); + this.ForLocale.TryGetValue(key, out Translation? translation); return translation ?? new Translation(this.Locale, key, null); } /// <summary>Get a translation for the current locale.</summary> /// <param name="key">The translation key.</param> /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> - public Translation Get(string key, object tokens) + public Translation Get(string key, object? tokens) { return this.Get(key).Tokens(tokens); } @@ -85,7 +88,7 @@ namespace StardewModdingAPI.Framework foreach (var localeSet in this.All) { string locale = localeSet.Key; - string text = this.GetRaw(key, locale, withFallback); + string? text = this.GetRaw(key, locale, withFallback); if (text != null) translations[locale] = new Translation(locale, key, text); @@ -126,13 +129,13 @@ namespace StardewModdingAPI.Framework /// <param name="key">The translation key.</param> /// <param name="locale">The locale to get.</param> /// <param name="withFallback">Whether to add duplicate translations for locale fallback. For example, if a translation is defined in <c>default.json</c> but not <c>fr.json</c>, setting this to true will add a <c>fr</c> entry which duplicates the default text.</param> - private string GetRaw(string key, string locale, bool withFallback) + private string? GetRaw(string key, string locale, bool withFallback) { foreach (string next in this.GetRelevantLocales(locale)) { - string translation = null; + string? translation = null; bool hasTranslation = - this.All.TryGetValue(next, out IDictionary<string, string> translations) + this.All.TryGetValue(next, out IDictionary<string, string>? translations) && translations.TryGetValue(key, out translation); if (hasTranslation) diff --git a/src/SMAPI/Framework/Utilities/Countdown.cs b/src/SMAPI/Framework/Utilities/Countdown.cs index 342b4258..94c69e73 100644 --- a/src/SMAPI/Framework/Utilities/Countdown.cs +++ b/src/SMAPI/Framework/Utilities/Countdown.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.Utilities +namespace StardewModdingAPI.Framework.Utilities { /// <summary>Counts down from a baseline value.</summary> internal class Countdown diff --git a/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs new file mode 100644 index 00000000..20d206e2 --- /dev/null +++ b/src/SMAPI/Framework/Utilities/TickCacheDictionary.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.Utilities +{ + /// <summary>An in-memory dictionary cache that stores data for the duration of a game update tick.</summary> + /// <typeparam name="TKey">The dictionary key type.</typeparam> + /// <typeparam name="TValue">The dictionary value type.</typeparam> + internal class TickCacheDictionary<TKey, TValue> + where TKey : notnull + { + /********* + ** Fields + *********/ + /// <summary>The last game tick for which data was cached.</summary> + private uint? LastGameTick; + + /// <summary>The underlying cached data.</summary> + private readonly Dictionary<TKey, TValue> Cache = new(); + + + /********* + ** Public methods + *********/ + /// <summary>Get a value from the cache, fetching it first if it's not cached yet.</summary> + /// <param name="cacheKey">The unique key for the cached value.</param> + /// <param name="get">Get the latest data if it's not in the cache yet.</param> + public TValue GetOrSet(TKey cacheKey, Func<TValue> get) + { + // clear cache on new tick + if (SCore.ProcessTicksElapsed != this.LastGameTick) + { + this.Cache.Clear(); + this.LastGameTick = SCore.ProcessTicksElapsed; + } + + // fetch value + if (!this.Cache.TryGetValue(cacheKey, out TValue? cached)) + this.Cache[cacheKey] = cached = get(); + return cached; + } + + /// <summary>Remove an entry from the cache.</summary> + /// <param name="cacheKey">The unique key for the cached value.</param> + /// <returns>Returns whether the key was present in the dictionary.</returns> + public bool Remove(TKey cacheKey) + { + return this.Cache.Remove(cacheKey); + } + } +} diff --git a/src/SMAPI/Framework/WatcherCore.cs b/src/SMAPI/Framework/WatcherCore.cs index 62a0c3b8..5e20ac7b 100644 --- a/src/SMAPI/Framework/WatcherCore.cs +++ b/src/SMAPI/Framework/WatcherCore.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Framework ** Fields *********/ /// <summary>The underlying watchers for convenience. These are accessible individually as separate properties.</summary> - private readonly List<IWatcher> Watchers = new List<IWatcher>(); + private readonly List<IWatcher> Watchers = new(); /********* @@ -27,7 +27,7 @@ namespace StardewModdingAPI.Framework public readonly IValueWatcher<Point> WindowSizeWatcher; /// <summary>Tracks changes to the current player.</summary> - public PlayerTracker CurrentPlayerTracker; + public PlayerTracker? CurrentPlayerTracker; /// <summary>Tracks changes to the time of day (in 24-hour military format).</summary> public readonly IValueWatcher<int> TimeWatcher; diff --git a/src/SMAPI/GameFramework.cs b/src/SMAPI/GameFramework.cs index a0154329..60fbe56e 100644 --- a/src/SMAPI/GameFramework.cs +++ b/src/SMAPI/GameFramework.cs @@ -6,7 +6,7 @@ namespace StardewModdingAPI public enum GameFramework { /// <summary>The XNA Framework, previously used on Windows.</summary> - [Obsolete("Stardew Valley no longer uses XNA Framework on any supported platform.")] + [Obsolete("Stardew Valley no longer uses XNA Framework on any supported platform. This value will be removed in SMAPI 4.0.0.")] Xna, /// <summary>The MonoGame framework.</summary> diff --git a/src/SMAPI/IAssetData.cs b/src/SMAPI/IAssetData.cs index 8df59e53..1ec09d87 100644 --- a/src/SMAPI/IAssetData.cs +++ b/src/SMAPI/IAssetData.cs @@ -5,6 +5,7 @@ namespace StardewModdingAPI /// <summary>Generic metadata and methods for a content asset being loaded.</summary> /// <typeparam name="TValue">The expected data type.</typeparam> public interface IAssetData<TValue> : IAssetInfo + where TValue : notnull { /********* ** Accessors diff --git a/src/SMAPI/IAssetDataForImage.cs b/src/SMAPI/IAssetDataForImage.cs index 27ed9267..6f8a4719 100644 --- a/src/SMAPI/IAssetDataForImage.cs +++ b/src/SMAPI/IAssetDataForImage.cs @@ -21,8 +21,8 @@ namespace StardewModdingAPI void PatchImage(Texture2D source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMode patchMode = PatchMode.Replace); /// <summary>Extend the image if needed to fit the given size. Note that this is an expensive operation, creates a new texture instance, and that extending a spritesheet horizontally may cause game errors or bugs.</summary> - /// <param name="minWidth">The minimum texture width.</param> - /// <param name="minHeight">The minimum texture height.</param> + /// <param name="minWidth">The minimum texture width in pixels.</param> + /// <param name="minHeight">The minimum texture height in pixels.</param> /// <returns>Whether the texture was resized.</returns> bool ExtendImage(int minWidth, int minHeight); } diff --git a/src/SMAPI/IAssetDataForMap.cs b/src/SMAPI/IAssetDataForMap.cs index 47a33de8..dcc7135d 100644 --- a/src/SMAPI/IAssetDataForMap.cs +++ b/src/SMAPI/IAssetDataForMap.cs @@ -15,5 +15,11 @@ namespace StardewModdingAPI /// <param name="targetArea">The tile area within the target map to overwrite, or <c>null</c> to patch the whole map. The original content within this area will be erased. This must be within the bounds of the existing map.</param> /// <param name="patchMode">Indicates how the map should be patched.</param> void PatchMap(Map source, Rectangle? sourceArea = null, Rectangle? targetArea = null, PatchMapMode patchMode = PatchMapMode.Overlay); + + /// <summary>Extend the map if needed to fit the given size. Note that this is an expensive operation and resizes the map in-place.</summary> + /// <param name="minWidth">The minimum map width in tiles.</param> + /// <param name="minHeight">The minimum map height in tiles.</param> + /// <returns>Whether the map was resized.</returns> + bool ExtendMap(int minWidth = 0, int minHeight = 0); } } diff --git a/src/SMAPI/IAssetEditor.cs b/src/SMAPI/IAssetEditor.cs index d2c6f295..9f22ed83 100644 --- a/src/SMAPI/IAssetEditor.cs +++ b/src/SMAPI/IAssetEditor.cs @@ -1,6 +1,10 @@ -namespace StardewModdingAPI +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI { /// <summary>Edits matching content assets.</summary> + [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This interface will be removed in SMAPI 4.0.0.")] public interface IAssetEditor { /********* diff --git a/src/SMAPI/IAssetInfo.cs b/src/SMAPI/IAssetInfo.cs index 6cdf01ee..44fd91a5 100644 --- a/src/SMAPI/IAssetInfo.cs +++ b/src/SMAPI/IAssetInfo.cs @@ -9,9 +9,19 @@ namespace StardewModdingAPI ** Accessors *********/ /// <summary>The content's locale code, if the content is localized.</summary> - string Locale { get; } + /// <remarks>LEGACY NOTE: when reading this field from an <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/> implementation, for non-localized assets it will return the current game locale (or an empty string for English) instead of null.</remarks> + string? Locale { get; } + + /// <summary>The asset name being read.</summary> + /// <remarks>LEGACY NOTE: when reading this field from an <see cref="IAssetLoader"/> or <see cref="IAssetEditor"/> implementation, it's always equivalent to <see cref="NameWithoutLocale"/> for backwards compatibility.</remarks> + public IAssetName Name { get; } + + /// <summary>The <see cref="Name"/> with any locale codes stripped.</summary> + /// <remarks>For example, if <see cref="Name"/> contains a locale like <c>Data/Bundles.fr-FR</c>, this will be the name without locale like <c>Data/Bundles</c>. If the name has no locale, this field is equivalent.</remarks> + public IAssetName NameWithoutLocale { get; } /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + [Obsolete($"Use {nameof(Name)} or {nameof(NameWithoutLocale)} instead. This property will be removed in SMAPI 4.0.0.")] string AssetName { get; } /// <summary>The content data type.</summary> @@ -23,6 +33,7 @@ namespace StardewModdingAPI *********/ /// <summary>Get whether the asset name being loaded matches a given name after normalization.</summary> /// <param name="path">The expected asset path, relative to the game's content folder and without the .xnb extension or locale suffix (like 'Data\ObjectInformation').</param> + [Obsolete($"Use {nameof(Name)}.{nameof(IAssetName.IsEquivalentTo)} or {nameof(NameWithoutLocale)}.{nameof(IAssetName.IsEquivalentTo)} instead. This method will be removed in SMAPI 4.0.0.")] bool AssetNameEquals(string path); } } diff --git a/src/SMAPI/IAssetLoader.cs b/src/SMAPI/IAssetLoader.cs index ad97b941..96b98793 100644 --- a/src/SMAPI/IAssetLoader.cs +++ b/src/SMAPI/IAssetLoader.cs @@ -1,6 +1,10 @@ -namespace StardewModdingAPI +using System; +using StardewModdingAPI.Events; + +namespace StardewModdingAPI { /// <summary>Provides the initial version for matching assets loaded by the game. SMAPI will raise an error if two mods try to load the same asset; in most cases you should use <see cref="IAssetEditor"/> instead.</summary> + [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This interface will be removed in SMAPI 4.0.0.")] public interface IAssetLoader { /********* diff --git a/src/SMAPI/IAssetName.cs b/src/SMAPI/IAssetName.cs new file mode 100644 index 00000000..71b6ed6b --- /dev/null +++ b/src/SMAPI/IAssetName.cs @@ -0,0 +1,52 @@ +using System; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>The name for an asset loaded through the content pipeline.</summary> + public interface IAssetName : IEquatable<IAssetName> + { + /********* + ** Accessors + *********/ + /// <summary>The full normalized asset name, including the locale if applicable (like <c>Data/Achievements.fr-FR</c>).</summary> + string Name { get; } + + /// <summary>The base asset name without the locale code.</summary> + string BaseName { get; } + + /// <summary>The locale code specified in the <see cref="Name"/>, if it's a valid code recognized by the game content.</summary> + string? LocaleCode { get; } + + /// <summary>The language code matching the <see cref="LocaleCode"/>, if applicable.</summary> + LocalizedContentManager.LanguageCode? LanguageCode { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get whether the given asset name is equivalent, ignoring capitalization and formatting.</summary> + /// <param name="assetName">The asset name to compare this instance to.</param> + /// <param name="useBaseName">Whether to compare the given name with the <see cref="BaseName"/> (if true) or <see cref="Name"/> (if false). This has no effect on any locale included in the given <paramref name="assetName"/>.</param> + bool IsEquivalentTo(string? assetName, bool useBaseName = false); + + /// <summary>Get whether the given asset name is equivalent, ignoring capitalization and formatting.</summary> + /// <param name="assetName">The asset name to compare this instance to.</param> + /// <param name="useBaseName">Whether to compare the given name with the <see cref="BaseName"/> (if true) or <see cref="Name"/> (if false).</param> + bool IsEquivalentTo(IAssetName? assetName, bool useBaseName = false); + + /// <summary>Get whether the asset name starts with the given value, ignoring capitalization and formatting. This can be used with a trailing slash to test for an asset folder, like <c>Data/</c>.</summary> + /// <param name="prefix">The prefix to match.</param> + /// <param name="allowPartialWord">Whether to match if the prefix occurs mid-word, so <c>Data/AchievementsToIgnore</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name starts with the prefix followed by a non-alphanumeric character (including <c>.</c>, <c>/</c>, or <c>\\</c>) or the end of string.</param> + /// <param name="allowSubfolder">Whether to match the prefix if there's a subfolder path after it, so <c>Data/Achievements/Example</c> matches prefix <c>Data/Achievements</c>. If this is false, the prefix only matches if the asset name has no <c>/</c> or <c>\\</c> characters after the prefix.</param> + bool StartsWith(string? prefix, bool allowPartialWord = true, bool allowSubfolder = true); + + /// <summary>Get whether the asset is directly within the given asset path.</summary> + /// <remarks>For example, <c>Characters/Dialogue/Abigail</c> is directly under <c>Characters/Dialogue</c> but not <c>Characters</c> or <c>Characters/Dialogue/Ab</c>. To allow sub-paths, use <see cref="StartsWith"/> instead.</remarks> + /// <param name="assetFolder">The asset path to check. This doesn't need a trailing slash.</param> + bool IsDirectlyUnderPath(string? assetFolder); + + /// <summary>Get an asset name representing the <see cref="BaseName"/> without locale.</summary> + internal IAssetName GetBaseAssetName(); + } +} diff --git a/src/SMAPI/ICommandHelper.cs b/src/SMAPI/ICommandHelper.cs index 196e1051..9f1c345c 100644 --- a/src/SMAPI/ICommandHelper.cs +++ b/src/SMAPI/ICommandHelper.cs @@ -21,6 +21,7 @@ namespace StardewModdingAPI /// <param name="name">The command name.</param> /// <param name="arguments">The command arguments.</param> /// <returns>Returns whether a matching command was triggered.</returns> + [Obsolete("Use mod-provided APIs to integrate with mods instead. This method will be removed in SMAPI 4.0.0.")] bool Trigger(string name, string[] arguments); } } diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index 2936ecfb..2cd0c1fc 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -3,21 +3,25 @@ using System.Collections.Generic; using System.Diagnostics.Contracts; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; using StardewValley; using xTile; namespace StardewModdingAPI { /// <summary>Provides an API for loading content assets.</summary> + [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.GameContent)} or {nameof(IMod.Helper)}.{nameof(IModHelper.ModContent)} instead. This interface will be removed in SMAPI 4.0.0.")] public interface IContentHelper : IModLinked { /********* ** Accessors *********/ /// <summary>Interceptors which provide the initial versions of matching content assets.</summary> + [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This property will be removed in SMAPI 4.0.0.")] IList<IAssetLoader> AssetLoaders { get; } /// <summary>Interceptors which edit matching content assets after they're loaded.</summary> + [Obsolete($"Use {nameof(IMod.Helper)}.{nameof(IModHelper.Events)}.{nameof(IModEvents.Content)} instead. This property will be removed in SMAPI 4.0.0.")] IList<IAssetEditor> AssetEditors { get; } /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> @@ -31,17 +35,19 @@ namespace StardewModdingAPI ** Public methods *********/ /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> - /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> /// <param name="source">Where to search for a matching content asset.</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> - T Load<T>(string key, ContentSource source = ContentSource.ModFolder); + T Load<T>(string key, ContentSource source = ContentSource.ModFolder) + where T : notnull; /// <summary>Normalize an asset name so it's consistent with those generated by the game. This is mainly useful for string comparisons like <see cref="string.StartsWith(string)"/> on generated asset names, and isn't necessary when passing asset names into other content helper methods.</summary> /// <param name="assetName">The asset key.</param> + /// <exception cref="ContentLoadException">The asset key is empty or contains invalid characters.</exception> [Pure] - string NormalizeAssetName(string assetName); + string NormalizeAssetName(string? assetName); /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> /// <param name="key">The asset key to fetch (if the <paramref name="source"/> is <see cref="ContentSource.GameContent"/>), or the local path to a content file relative to the mod folder.</param> @@ -58,7 +64,8 @@ namespace StardewModdingAPI /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> /// <typeparam name="T">The asset type to remove from the cache.</typeparam> /// <returns>Returns whether any assets were invalidated.</returns> - bool InvalidateCache<T>(); + bool InvalidateCache<T>() + where T : notnull; /// <summary>Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> /// <param name="predicate">A predicate matching the assets to invalidate.</param> @@ -69,6 +76,7 @@ namespace StardewModdingAPI /// <typeparam name="T">The data type.</typeparam> /// <param name="data">The asset data.</param> /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> - IAssetData GetPatchHelper<T>(T data, string assetName = null); + IAssetData GetPatchHelper<T>(T data, string? assetName = null) + where T : notnull; } } diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 9cc64dcd..1215fe0b 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -20,6 +20,9 @@ namespace StardewModdingAPI /// <summary>Provides translations stored in the content pack's <c>i18n</c> folder. See <see cref="IModHelper.Translation"/> for more info.</summary> ITranslationHelper Translation { get; } + /// <summary>An API for loading content assets from the content pack's files.</summary> + IModContentHelper ModContent { get; } + /********* ** Public methods @@ -33,25 +36,30 @@ namespace StardewModdingAPI /// <param name="path">The relative file path within the content pack (case-insensitive).</param> /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> - TModel ReadJsonFile<TModel>(string path) where TModel : class; + TModel? ReadJsonFile<TModel>(string path) + where TModel : class; /// <summary>Save data to a JSON file in the content pack's folder.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The relative file path within the content pack (case-insensitive).</param> /// <param name="data">The arbitrary data to save.</param> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> - void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + void WriteJsonFile<TModel>(string path, TModel data) + where TModel : class; /// <summary>Load content from the content pack folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, and dictionaries; other types may be supported by the game's content pipeline.</typeparam> /// <param name="key">The relative file path within the content pack (case-insensitive).</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> - T LoadAsset<T>(string key); + [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.Load)} instead. This method will be removed in SMAPI 4.0.0.")] + T LoadAsset<T>(string key) + where T : notnull; /// <summary>Get the underlying key in the game's content cache for an asset. This can be used to load custom map tilesheets, but should be avoided when you can use the content API instead. This does not validate whether the asset exists.</summary> /// <param name="key">The relative file path within the content pack (case-insensitive).</param> /// <exception cref="ArgumentException">The <paramref name="key"/> is empty or contains invalid characters.</exception> + [Obsolete($"Use {nameof(IContentPack.ModContent)}.{nameof(IModContentHelper.GetInternalAssetName)} instead. This method will be removed in SMAPI 4.0.0.")] string GetActualAssetKey(string key); } } diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs index 901266d7..7ddf851e 100644 --- a/src/SMAPI/IDataHelper.cs +++ b/src/SMAPI/IDataHelper.cs @@ -16,14 +16,16 @@ namespace StardewModdingAPI /// <param name="path">The file path relative to the mod folder.</param> /// <returns>Returns the deserialized model, or <c>null</c> if the file doesn't exist or is empty.</returns> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> - TModel ReadJsonFile<TModel>(string path) where TModel : class; + TModel? ReadJsonFile<TModel>(string path) + where TModel : class; /// <summary>Save data to a JSON file in the mod's folder.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the mod folder.</param> /// <param name="data">The arbitrary data to save, or <c>null</c> to delete the file.</param> /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> - void WriteJsonFile<TModel>(string path, TModel data) where TModel : class; + void WriteJsonFile<TModel>(string path, TModel? data) + where TModel : class; /**** ** Save file @@ -33,14 +35,16 @@ namespace StardewModdingAPI /// <param name="key">The unique key identifying the data.</param> /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> - TModel ReadSaveData<TModel>(string key) where TModel : class; + TModel? ReadSaveData<TModel>(string key) + where TModel : class; /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="key">The unique key identifying the data.</param> /// <param name="data">The arbitrary data to save, or <c>null</c> to remove the entry.</param> /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> - void WriteSaveData<TModel>(string key, TModel data) where TModel : class; + void WriteSaveData<TModel>(string key, TModel? data) + where TModel : class; /**** @@ -50,12 +54,14 @@ namespace StardewModdingAPI /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="key">The unique key identifying the data.</param> /// <returns>Returns the parsed data, or <c>null</c> if the entry doesn't exist or is empty.</returns> - TModel ReadGlobalData<TModel>(string key) where TModel : class; + TModel? ReadGlobalData<TModel>(string key) + where TModel : class; /// <summary>Save arbitrary data to the local computer, synchronised by GOG/Steam if applicable.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="key">The unique key identifying the data.</param> /// <param name="data">The arbitrary data to save, or <c>null</c> to delete the file.</param> - void WriteGlobalData<TModel>(string key, TModel data) where TModel : class; + void WriteGlobalData<TModel>(string key, TModel? data) + where TModel : class; } } diff --git a/src/SMAPI/IGameContentHelper.cs b/src/SMAPI/IGameContentHelper.cs new file mode 100644 index 00000000..d40d0c82 --- /dev/null +++ b/src/SMAPI/IGameContentHelper.cs @@ -0,0 +1,77 @@ +using System; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Events; +using StardewValley; +using xTile; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for loading content assets from the game's <c>Content</c> folder or via <see cref="IModEvents.Content"/>.</summary> + public interface IGameContentHelper : IModLinked + { + /********* + ** Accessors + *********/ + /// <summary>The game's current locale code (like <c>pt-BR</c>).</summary> + string CurrentLocale { get; } + + /// <summary>The game's current locale as an enum value.</summary> + LocalizedContentManager.LanguageCode CurrentLocaleConstant { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Parse a raw asset name.</summary> + /// <param name="rawName">The raw asset name to parse.</param> + /// <exception cref="ArgumentException">The <paramref name="rawName"/> is null or empty.</exception> + IAssetName ParseAssetName(string rawName); + + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="assetName">The asset name to load.</param> + /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T Load<T>(string assetName) + where T : notnull; + + /// <summary>Load content from the game folder or mod folder (if not already cached), and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="assetName">The asset name to load.</param> + /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T Load<T>(IAssetName assetName) + where T : notnull; + + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="assetName">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + bool InvalidateCache(string assetName); + + /// <summary>Remove an asset from the content cache so it's reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="assetName">The asset key to invalidate in the content folder.</param> + /// <exception cref="ArgumentException">The <paramref name="assetName"/> is empty or contains invalid characters.</exception> + /// <returns>Returns whether the given asset key was cached.</returns> + bool InvalidateCache(IAssetName assetName); + + /// <summary>Remove all assets of the given type from the cache so they're reloaded on the next request. <b>This can be a very expensive operation and should only be used in very specific cases.</b> This will reload core game assets if needed, but references to the former assets will still show the previous content.</summary> + /// <typeparam name="T">The asset type to remove from the cache.</typeparam> + /// <returns>Returns whether any assets were invalidated.</returns> + bool InvalidateCache<T>() + where T : notnull; + + /// <summary>Remove matching assets from the content cache so they're reloaded on the next request. This will reload core game assets if needed, but references to the former asset will still show the previous content.</summary> + /// <param name="predicate">A predicate matching the assets to invalidate.</param> + /// <returns>Returns whether any cache entries were invalidated.</returns> + bool InvalidateCache(Func<IAssetInfo, bool> predicate); + + /// <summary>Get a patch helper for arbitrary data.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The asset data.</param> + /// <param name="assetName">The asset name. This is only used for tracking purposes and has no effect on the patch helper.</param> + IAssetData GetPatchHelper<T>(T data, string? assetName = null) + where T : notnull; + } +} diff --git a/src/SMAPI/IMod.cs b/src/SMAPI/IMod.cs index 44ef32c9..b81ba0e3 100644 --- a/src/SMAPI/IMod.cs +++ b/src/SMAPI/IMod.cs @@ -24,6 +24,6 @@ namespace StardewModdingAPI void Entry(IModHelper helper); /// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary> - object GetApi(); + object? GetApi(); } } diff --git a/src/SMAPI/IModContentHelper.cs b/src/SMAPI/IModContentHelper.cs new file mode 100644 index 00000000..f1f6ce94 --- /dev/null +++ b/src/SMAPI/IModContentHelper.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.Xna.Framework.Content; +using Microsoft.Xna.Framework.Graphics; +using xTile; + +namespace StardewModdingAPI +{ + /// <summary>Provides an API for loading content assets from the current mod's folder.</summary> + public interface IModContentHelper : IModLinked + { + /********* + ** Public methods + *********/ + /// <summary>Load content from the mod folder and return it. When loading a <c>.png</c> file, this must be called outside the game's draw loop.</summary> + /// <typeparam name="T">The expected data type. The main supported types are <see cref="Map"/>, <see cref="Texture2D"/>, dictionaries, and lists; other types may be supported by the game's content pipeline.</typeparam> + /// <param name="relativePath">The local path to a content file relative to the mod folder.</param> + /// <exception cref="ArgumentException">The <paramref name="relativePath"/> is empty or contains invalid characters.</exception> + /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> + T Load<T>(string relativePath) + where T : notnull; + + /// <summary>Get the internal asset name which allows loading a mod file through any of the game's content managers. This can be used when passing asset names directly to the game (e.g. for map tilesheets), but should be avoided if you can use <see cref="Load{T}"/> instead. This does not validate whether the asset exists.</summary> + /// <param name="relativePath">The local path to a content file relative to the mod folder.</param> + /// <exception cref="ArgumentException">The <paramref name="relativePath"/> is empty or contains invalid characters.</exception> + IAssetName GetInternalAssetName(string relativePath); + + /// <summary>Get a patch helper for arbitrary data.</summary> + /// <typeparam name="T">The data type.</typeparam> + /// <param name="data">The asset data.</param> + /// <param name="relativePath">The local path to the content file being edited relative to the mod folder. This is only used for tracking purposes and has no effect on the patch helper.</param> + IAssetData GetPatchHelper<T>(T data, string? relativePath = null) + where T : notnull; + } +} diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index cd746e06..15e4ed8d 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -1,3 +1,4 @@ +using System; using StardewModdingAPI.Events; namespace StardewModdingAPI @@ -17,13 +18,22 @@ namespace StardewModdingAPI /// <summary>An API for managing console commands.</summary> ICommandHelper ConsoleCommands { get; } + /// <summary>An API for loading content assets from the game's <c>Content</c> folder or using the <see cref="IModEvents.Content"/> events.</summary> + IGameContentHelper GameContent { get; } + + /// <summary>An API for loading content assets from your mod's files.</summary> + /// <remarks>This API is intended for reading content assets from the mod files (like game data, images, etc); see also <see cref="Data"/> which is intended for persisting internal mod data.</remarks> + IModContentHelper ModContent { get; } + /// <summary>An API for loading content assets.</summary> + [Obsolete($"Use {nameof(IGameContentHelper)} or {nameof(IModContentHelper)} instead.")] IContentHelper Content { get; } /// <summary>An API for managing content packs.</summary> IContentPackHelper ContentPacks { get; } /// <summary>An API for reading and writing persistent mod data.</summary> + /// <remarks>This API is intended for persisting internal mod data; see also <see cref="ModContent"/> which is intended for reading content assets (like game data, images, etc).</remarks> IDataHelper Data { get; } /// <summary>An API for checking and changing input state.</summary> diff --git a/src/SMAPI/IModRegistry.cs b/src/SMAPI/IModRegistry.cs index 10b3121e..cf60bc29 100644 --- a/src/SMAPI/IModRegistry.cs +++ b/src/SMAPI/IModRegistry.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI /// <summary>Get metadata for a loaded mod.</summary> /// <param name="uniqueID">The mod's unique ID.</param> /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> - IModInfo Get(string uniqueID); + IModInfo? Get(string uniqueID); /// <summary>Get whether a mod has been loaded.</summary> /// <param name="uniqueID">The mod's unique ID.</param> @@ -19,11 +19,12 @@ namespace StardewModdingAPI /// <summary>Get the API provided by a mod, or <c>null</c> if it has none. This signature requires using the <see cref="IModHelper.Reflection"/> API to access the API's properties and methods.</summary> /// <param name="uniqueID">The mod's unique ID.</param> - object GetApi(string uniqueID); + object? GetApi(string uniqueID); /// <summary>Get the API provided by a mod, mapped to a given interface which specifies the expected properties and methods. If the mod has no API or it's not compatible with the given interface, get <c>null</c>.</summary> /// <typeparam name="TInterface">The interface which matches the properties and methods you intend to access.</typeparam> /// <param name="uniqueID">The mod's unique ID.</param> - TInterface GetApi<TInterface>(string uniqueID) where TInterface : class; + TInterface? GetApi<TInterface>(string uniqueID) + where TInterface : class; } } diff --git a/src/SMAPI/IMultiplayerHelper.cs b/src/SMAPI/IMultiplayerHelper.cs index 4067a676..bb851410 100644 --- a/src/SMAPI/IMultiplayerHelper.cs +++ b/src/SMAPI/IMultiplayerHelper.cs @@ -16,7 +16,7 @@ namespace StardewModdingAPI /// <summary>Get a connected player.</summary> /// <param name="id">The player's unique ID.</param> /// <returns>Returns the connected player, or <c>null</c> if no such player is connected.</returns> - IMultiplayerPeer GetConnectedPlayer(long id); + IMultiplayerPeer? GetConnectedPlayer(long id); /// <summary>Get all connected players.</summary> IEnumerable<IMultiplayerPeer> GetConnectedPlayers(); @@ -28,6 +28,6 @@ namespace StardewModdingAPI /// <param name="modIDs">The mod IDs which should receive the message on the destination computers, or <c>null</c> for all mods. Specifying mod IDs is recommended to improve performance, unless it's a general-purpose broadcast.</param> /// <param name="playerIDs">The <see cref="Farmer.UniqueMultiplayerID" /> values for the players who should receive the message, or <c>null</c> for all players. If you don't need to broadcast to all players, specifying player IDs is recommended to reduce latency.</param> /// <exception cref="ArgumentNullException">The <paramref name="message"/> or <paramref name="messageType" /> is null.</exception> - void SendMessage<TMessage>(TMessage message, string messageType, string[] modIDs = null, long[] playerIDs = null); + void SendMessage<TMessage>(TMessage message, string messageType, string[]? modIDs = null, long[]? playerIDs = null); } } diff --git a/src/SMAPI/IMultiplayerPeer.cs b/src/SMAPI/IMultiplayerPeer.cs index 47084174..8b0062b5 100644 --- a/src/SMAPI/IMultiplayerPeer.cs +++ b/src/SMAPI/IMultiplayerPeer.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; namespace StardewModdingAPI { @@ -18,6 +19,7 @@ namespace StardewModdingAPI bool IsSplitScreen { get; } /// <summary>Whether the player has SMAPI installed.</summary> + [MemberNotNullWhen(true, nameof(IMultiplayerPeer.Platform), nameof(IMultiplayerPeer.GameVersion), nameof(IMultiplayerPeer.ApiVersion), nameof(IMultiplayerPeer.Mods))] bool HasSmapi { get; } /// <summary>The player's screen ID, if applicable.</summary> @@ -28,10 +30,10 @@ namespace StardewModdingAPI GamePlatform? Platform { get; } /// <summary>The installed version of Stardew Valley, if <see cref="HasSmapi"/> is true.</summary> - ISemanticVersion GameVersion { get; } + ISemanticVersion? GameVersion { get; } /// <summary>The installed version of SMAPI, if <see cref="HasSmapi"/> is true.</summary> - ISemanticVersion ApiVersion { get; } + ISemanticVersion? ApiVersion { get; } /// <summary>The installed mods, if <see cref="HasSmapi"/> is true.</summary> IEnumerable<IMultiplayerPeerMod> Mods { get; } @@ -43,6 +45,6 @@ namespace StardewModdingAPI /// <summary>Get metadata for a mod installed by the player.</summary> /// <param name="id">The unique mod ID.</param> /// <returns>Returns the mod info, or <c>null</c> if the player doesn't have that mod.</returns> - IMultiplayerPeerMod GetMod(string id); + IMultiplayerPeerMod? GetMod(string? id); } } diff --git a/src/SMAPI/IReflectedField.cs b/src/SMAPI/IReflectedField.cs index 7ff61f29..8b00909c 100644 --- a/src/SMAPI/IReflectedField.cs +++ b/src/SMAPI/IReflectedField.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; namespace StardewModdingAPI { diff --git a/src/SMAPI/IReflectedMethod.cs b/src/SMAPI/IReflectedMethod.cs index 646e7301..04636b84 100644 --- a/src/SMAPI/IReflectedMethod.cs +++ b/src/SMAPI/IReflectedMethod.cs @@ -1,4 +1,4 @@ -using System.Reflection; +using System.Reflection; namespace StardewModdingAPI { @@ -18,10 +18,10 @@ namespace StardewModdingAPI /// <summary>Invoke the method.</summary> /// <typeparam name="TValue">The return type.</typeparam> /// <param name="arguments">The method arguments to pass in.</param> - TValue Invoke<TValue>(params object[] arguments); + TValue Invoke<TValue>(params object?[] arguments); /// <summary>Invoke the method.</summary> /// <param name="arguments">The method arguments to pass in.</param> - void Invoke(params object[] arguments); + void Invoke(params object?[] arguments); } } diff --git a/src/SMAPI/IReflectionHelper.cs b/src/SMAPI/IReflectionHelper.cs index a2b9eb32..b8fb877f 100644 --- a/src/SMAPI/IReflectionHelper.cs +++ b/src/SMAPI/IReflectionHelper.cs @@ -12,40 +12,52 @@ namespace StardewModdingAPI /// <typeparam name="TValue">The field type.</typeparam> /// <param name="obj">The object which has the field.</param> /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedField<TValue> GetField<TValue>(object obj, string name, bool required = true); /// <summary>Get a static field.</summary> /// <typeparam name="TValue">The field type.</typeparam> /// <param name="type">The type which has the field.</param> /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="required">Whether to throw an exception if the field isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the field doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target field doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedField<TValue> GetField<TValue>(Type type, string name, bool required = true); /// <summary>Get an instance property.</summary> /// <typeparam name="TValue">The property type.</typeparam> /// <param name="obj">The object which has the property.</param> /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedProperty<TValue> GetProperty<TValue>(object obj, string name, bool required = true); /// <summary>Get a static property.</summary> /// <typeparam name="TValue">The property type.</typeparam> /// <param name="type">The type which has the property.</param> /// <param name="name">The property name.</param> - /// <param name="required">Whether to throw an exception if the property is not found.</param> + /// <param name="required">Whether to throw an exception if the property isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the property doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target property doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedProperty<TValue> GetProperty<TValue>(Type type, string name, bool required = true); /// <summary>Get an instance method.</summary> /// <param name="obj">The object which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="name">The method name.</param> + /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedMethod GetMethod(object obj, string name, bool required = true); /// <summary>Get a static method.</summary> /// <param name="type">The type which has the method.</param> - /// <param name="name">The field name.</param> - /// <param name="required">Whether to throw an exception if the field is not found.</param> + /// <param name="name">The method name.</param> + /// <param name="required">Whether to throw an exception if the method isn't found. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the return value non-nullable.</strong></param> + /// <returns>Returns the method wrapper, or <c>null</c> if <paramref name="required"/> is <c>false</c> and the method doesn't exist.</returns> + /// <exception cref="InvalidOperationException">The target method doesn't exist, and <paramref name="required"/> is true.</exception> IReflectedMethod GetMethod(Type type, string name, bool required = true); } } diff --git a/src/SMAPI/ITranslationHelper.cs b/src/SMAPI/ITranslationHelper.cs index b30d9b14..8be8d2c1 100644 --- a/src/SMAPI/ITranslationHelper.cs +++ b/src/SMAPI/ITranslationHelper.cs @@ -9,7 +9,7 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>The current locale.</summary> + /// <summary>The current locale code like <c>fr-FR</c>, or an empty string for English.</summary> string Locale { get; } /// <summary>The game's current language code.</summary> @@ -29,7 +29,7 @@ namespace StardewModdingAPI /// <summary>Get a translation for the current locale.</summary> /// <param name="key">The translation key.</param> /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> - Translation Get(string key, object tokens); + Translation Get(string key, object? tokens); /// <summary>Get a translation in every locale for which it's defined.</summary> /// <param name="key">The translation key.</param> diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 552bc000..5dee2c4d 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -45,8 +45,8 @@ namespace StardewModdingAPI.Metadata /// <summary>Whether to enable more aggressive memory optimizations.</summary> private readonly bool AggressiveMemoryOptimizations; - /// <summary>Normalizes an asset key to match the cache key and assert that it's valid.</summary> - private readonly Func<string, string> AssertAndNormalizeAssetName; + /// <summary>Parse a raw asset name.</summary> + private readonly Func<string, IAssetName> ParseAssetName; /// <summary>Optimized bucket categories for batch reloading assets.</summary> private enum AssetBucket @@ -71,15 +71,15 @@ namespace StardewModdingAPI.Metadata /// <param name="monitor">Writes messages to the console.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="aggressiveMemoryOptimizations">Whether to enable more aggressive memory optimizations.</param> - public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, bool aggressiveMemoryOptimizations) + /// <param name="parseAssetName">Parse a raw asset name.</param> + public CoreAssetPropagator(LocalizedContentManager mainContent, GameContentManagerForAssetPropagation disposableContent, IMonitor monitor, Reflector reflection, bool aggressiveMemoryOptimizations, Func<string, IAssetName> parseAssetName) { this.MainContentManager = mainContent; this.DisposableContentManager = disposableContent; this.Monitor = monitor; this.Reflection = reflection; this.AggressiveMemoryOptimizations = aggressiveMemoryOptimizations; - - this.AssertAndNormalizeAssetName = disposableContent.AssertAndNormalizeAssetName; + this.ParseAssetName = parseAssetName; } /// <summary>Reload one of the game's core assets (if applicable).</summary> @@ -87,22 +87,27 @@ namespace StardewModdingAPI.Metadata /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <param name="propagatedAssets">A lookup of asset names to whether they've been propagated.</param> /// <param name="updatedNpcWarps">Whether the NPC pathfinding cache was reloaded.</param> - public void Propagate(IDictionary<string, Type> assets, bool ignoreWorld, out IDictionary<string, bool> propagatedAssets, out bool updatedNpcWarps) + public void Propagate(IDictionary<IAssetName, Type> assets, bool ignoreWorld, out IDictionary<IAssetName, bool> propagatedAssets, out bool updatedNpcWarps) { + // get base name lookup + propagatedAssets = assets + .Select(asset => asset.Key.GetBaseAssetName()) + .Distinct() + .ToDictionary(name => name, _ => false); + // group into optimized lists var buckets = assets.GroupBy(p => { - if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters\\Monsters")) + if (p.Key.IsDirectlyUnderPath("Characters") || p.Key.IsDirectlyUnderPath("Characters/Monsters")) return AssetBucket.Sprite; - if (this.IsInFolder(p.Key, "Portraits")) + if (p.Key.IsDirectlyUnderPath("Portraits")) return AssetBucket.Portrait; return AssetBucket.Other; }); // reload assets - propagatedAssets = assets.ToDictionary(p => p.Key, _ => false, StringComparer.OrdinalIgnoreCase); updatedNpcWarps = false; foreach (var bucket in buckets) { @@ -110,12 +115,12 @@ namespace StardewModdingAPI.Metadata { case AssetBucket.Sprite: if (!ignoreWorld) - this.ReloadNpcSprites(bucket.Select(p => p.Key), propagatedAssets); + this.ReloadNpcSprites(propagatedAssets); break; case AssetBucket.Portrait: if (!ignoreWorld) - this.ReloadNpcPortraits(bucket.Select(p => p.Key), propagatedAssets); + this.ReloadNpcPortraits(propagatedAssets); break; default: @@ -149,16 +154,16 @@ namespace StardewModdingAPI.Metadata ** Private methods *********/ /// <summary>Reload one of the game's core assets (if applicable).</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <param name="type">The asset type to reload.</param> /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <param name="changedWarps">Whether any map warps were changed as part of this propagation.</param> /// <returns>Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true.</returns> [SuppressMessage("ReSharper", "StringLiteralTypo", Justification = "These deliberately match the asset names.")] - private bool PropagateOther(string key, Type type, bool ignoreWorld, out bool changedWarps) + private bool PropagateOther(IAssetName assetName, Type type, bool ignoreWorld, out bool changedWarps) { var content = this.MainContentManager; - key = this.AssertAndNormalizeAssetName(key); + string key = assetName.BaseName; changedWarps = false; /**** @@ -170,7 +175,7 @@ namespace StardewModdingAPI.Metadata { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { - if (this.NormalizeAssetNameIgnoringEmpty(tilesheet.ImageSource) == key) + if (this.IsSameBaseName(assetName, tilesheet.ImageSource)) Game1.mapDisplayDevice.LoadTileSheet(tilesheet); } } @@ -188,7 +193,7 @@ namespace StardewModdingAPI.Metadata { GameLocation location = info.Location; - if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) + if (this.IsSameBaseName(assetName, location.mapPath.Value)) { static ISet<string> GetWarpSet(GameLocation location) { @@ -213,25 +218,24 @@ namespace StardewModdingAPI.Metadata /**** ** Propagate by key ****/ - Reflector reflection = this.Reflection; - switch (key.ToLower().Replace("/", "\\")) // normalized key so we can compare statically + switch (assetName.BaseName.ToLower().Replace("\\", "/")) // normalized key so we can compare statically { /**** ** Animals ****/ - case "animals\\horse": - return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, key); + case "animals/horse": + return !ignoreWorld && this.ReloadPetOrHorseSprites<Horse>(content, assetName); /**** ** Buildings ****/ - case "buildings\\houses": // Farm + case "buildings/houses": // Farm Farm.houseTextures = this.LoadAndDisposeIfNeeded(Farm.houseTextures, key); return true; - case "buildings\\houses_paintmask": // Farm + case "buildings/houses_paintmask": // Farm { - bool removedFromCache = this.RemoveFromPaintMaskCache(key); + bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); Farm farm = Game1.getFarm(); farm?.ApplyHousePaint(); @@ -242,149 +246,149 @@ namespace StardewModdingAPI.Metadata /**** ** Content\Characters\Farmer ****/ - case "characters\\farmer\\accessories": // Game1.LoadContent + case "characters/farmer/accessories": // Game1.LoadContent FarmerRenderer.accessoriesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.accessoriesTexture, key); return true; - case "characters\\farmer\\farmer_base": // Farmer - case "characters\\farmer\\farmer_base_bald": - case "characters\\farmer\\farmer_girl_base": - case "characters\\farmer\\farmer_girl_base_bald": - return !ignoreWorld && this.ReloadPlayerSprites(key); + case "characters/farmer/farmer_base": // Farmer + case "characters/farmer/farmer_base_bald": + case "characters/farmer/farmer_girl_base": + case "characters/farmer/farmer_girl_base_bald": + return !ignoreWorld && this.ReloadPlayerSprites(assetName); - case "characters\\farmer\\hairstyles": // Game1.LoadContent + case "characters/farmer/hairstyles": // Game1.LoadContent FarmerRenderer.hairStylesTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hairStylesTexture, key); return true; - case "characters\\farmer\\hats": // Game1.LoadContent + case "characters/farmer/hats": // Game1.LoadContent FarmerRenderer.hatsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.hatsTexture, key); return true; - case "characters\\farmer\\pants": // Game1.LoadContent + case "characters/farmer/pants": // Game1.LoadContent FarmerRenderer.pantsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.pantsTexture, key); return true; - case "characters\\farmer\\shirts": // Game1.LoadContent + case "characters/farmer/shirts": // Game1.LoadContent FarmerRenderer.shirtsTexture = this.LoadAndDisposeIfNeeded(FarmerRenderer.shirtsTexture, key); return true; /**** ** Content\Data ****/ - case "data\\achievements": // Game1.LoadContent + case "data/achievements": // Game1.LoadContent Game1.achievements = content.Load<Dictionary<int, string>>(key); return true; - case "data\\bigcraftablesinformation": // Game1.LoadContent + case "data/bigcraftablesinformation": // Game1.LoadContent Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key); return true; - case "data\\clothinginformation": // Game1.LoadContent + case "data/clothinginformation": // Game1.LoadContent Game1.clothingInformation = content.Load<Dictionary<int, string>>(key); return true; - case "data\\concessions": // MovieTheater.GetConcessions + case "data/concessions": // MovieTheater.GetConcessions MovieTheater.ClearCachedLocalizedData(); return true; - case "data\\concessiontastes": // MovieTheater.GetConcessionTasteForCharacter + case "data/concessiontastes": // MovieTheater.GetConcessionTasteForCharacter this.Reflection .GetField<List<ConcessionTaste>>(typeof(MovieTheater), "_concessionTastes") .SetValue(content.Load<List<ConcessionTaste>>(key)); return true; - case "data\\cookingrecipes": // CraftingRecipe.InitShared + case "data/cookingrecipes": // CraftingRecipe.InitShared CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key); return true; - case "data\\craftingrecipes": // CraftingRecipe.InitShared + case "data/craftingrecipes": // CraftingRecipe.InitShared CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key); return true; - case "data\\farmanimals": // FarmAnimal constructor + case "data/farmanimals": // FarmAnimal constructor return !ignoreWorld && this.ReloadFarmAnimalData(); - case "data\\hairdata": // Farmer.GetHairStyleMetadataFile + case "data/hairdata": // Farmer.GetHairStyleMetadataFile return this.ReloadHairData(); - case "data\\movies": // MovieTheater.GetMovieData - case "data\\moviesreactions": // MovieTheater.GetMovieReactions + case "data/movies": // MovieTheater.GetMovieData + case "data/moviesreactions": // MovieTheater.GetMovieReactions MovieTheater.ClearCachedLocalizedData(); return true; - case "data\\npcdispositions": // NPC constructor - return !ignoreWorld && this.ReloadNpcDispositions(content, key); + case "data/npcdispositions": // NPC constructor + return !ignoreWorld && this.ReloadNpcDispositions(content, assetName); - case "data\\npcgifttastes": // Game1.LoadContent + case "data/npcgifttastes": // Game1.LoadContent Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); return true; - case "data\\objectcontexttags": // Game1.LoadContent + case "data/objectcontexttags": // Game1.LoadContent Game1.objectContextTags = content.Load<Dictionary<string, string>>(key); return true; - case "data\\objectinformation": // Game1.LoadContent + case "data/objectinformation": // Game1.LoadContent Game1.objectInformation = content.Load<Dictionary<int, string>>(key); return true; /**** ** Content\Fonts ****/ - case "fonts\\spritefont1": // Game1.LoadContent + case "fonts/spritefont1": // Game1.LoadContent Game1.dialogueFont = content.Load<SpriteFont>(key); return true; - case "fonts\\smallfont": // Game1.LoadContent + case "fonts/smallfont": // Game1.LoadContent Game1.smallFont = content.Load<SpriteFont>(key); return true; - case "fonts\\tinyfont": // Game1.LoadContent + case "fonts/tinyfont": // Game1.LoadContent Game1.tinyFont = content.Load<SpriteFont>(key); return true; - case "fonts\\tinyfontborder": // Game1.LoadContent + case "fonts/tinyfontborder": // Game1.LoadContent Game1.tinyFontBorder = content.Load<SpriteFont>(key); return true; /**** ** Content\LooseSprites\Lighting ****/ - case "loosesprites\\lighting\\greenlight": // Game1.LoadContent + case "loosesprites/lighting/greenlight": // Game1.LoadContent Game1.cauldronLight = content.Load<Texture2D>(key); return true; - case "loosesprites\\lighting\\indoorwindowlight": // Game1.LoadContent + case "loosesprites/lighting/indoorwindowlight": // Game1.LoadContent Game1.indoorWindowLight = content.Load<Texture2D>(key); return true; - case "loosesprites\\lighting\\lantern": // Game1.LoadContent + case "loosesprites/lighting/lantern": // Game1.LoadContent Game1.lantern = content.Load<Texture2D>(key); return true; - case "loosesprites\\lighting\\sconcelight": // Game1.LoadContent + case "loosesprites/lighting/sconcelight": // Game1.LoadContent Game1.sconceLight = content.Load<Texture2D>(key); return true; - case "loosesprites\\lighting\\windowlight": // Game1.LoadContent + case "loosesprites/lighting/windowlight": // Game1.LoadContent Game1.windowLight = content.Load<Texture2D>(key); return true; /**** ** Content\LooseSprites ****/ - case "loosesprites\\birds": // Game1.LoadContent + case "loosesprites/birds": // Game1.LoadContent Game1.birdsSpriteSheet = content.Load<Texture2D>(key); return true; - case "loosesprites\\concessions": // Game1.LoadContent + case "loosesprites/concessions": // Game1.LoadContent Game1.concessionsSpriteSheet = content.Load<Texture2D>(key); return true; - case "loosesprites\\controllermaps": // Game1.LoadContent + case "loosesprites/controllermaps": // Game1.LoadContent Game1.controllerMaps = content.Load<Texture2D>(key); return true; - case "loosesprites\\cursors": // Game1.LoadContent + case "loosesprites/cursors": // Game1.LoadContent Game1.mouseCursors = content.Load<Texture2D>(key); foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType<DayTimeMoneyBox>()) { @@ -393,59 +397,59 @@ namespace StardewModdingAPI.Metadata } if (!ignoreWorld) - this.ReloadDoorSprites(content, key); + this.ReloadDoorSprites(content, assetName); return true; - case "loosesprites\\cursors2": // Game1.LoadContent + case "loosesprites/cursors2": // Game1.LoadContent Game1.mouseCursors2 = content.Load<Texture2D>(key); return true; - case "loosesprites\\daybg": // Game1.LoadContent + case "loosesprites/daybg": // Game1.LoadContent Game1.daybg = content.Load<Texture2D>(key); return true; - case "loosesprites\\font_bold": // Game1.LoadContent + case "loosesprites/font_bold": // Game1.LoadContent SpriteText.spriteTexture = content.Load<Texture2D>(key); return true; - case "loosesprites\\font_colored": // Game1.LoadContent + case "loosesprites/font_colored": // Game1.LoadContent SpriteText.coloredTexture = content.Load<Texture2D>(key); return true; - case "loosesprites\\giftbox": // Game1.LoadContent + case "loosesprites/giftbox": // Game1.LoadContent Game1.giftboxTexture = content.Load<Texture2D>(key); return true; - case "loosesprites\\nightbg": // Game1.LoadContent + case "loosesprites/nightbg": // Game1.LoadContent Game1.nightbg = content.Load<Texture2D>(key); return true; - case "loosesprites\\shadow": // Game1.LoadContent + case "loosesprites/shadow": // Game1.LoadContent Game1.shadowTexture = content.Load<Texture2D>(key); return true; - case "loosesprites\\suspensionbridge": // SuspensionBridge constructor - return !ignoreWorld && this.ReloadSuspensionBridges(content, key); + case "loosesprites/suspensionbridge": // SuspensionBridge constructor + return !ignoreWorld && this.ReloadSuspensionBridges(content, assetName); /**** ** Content\Maps ****/ - case "maps\\menutiles": // Game1.LoadContent + case "maps/menutiles": // Game1.LoadContent Game1.menuTexture = content.Load<Texture2D>(key); return true; - case "maps\\menutilesuncolored": // Game1.LoadContent + case "maps/menutilesuncolored": // Game1.LoadContent Game1.uncoloredMenuTexture = content.Load<Texture2D>(key); return true; - case "maps\\springobjects": // Game1.LoadContent + case "maps/springobjects": // Game1.LoadContent Game1.objectSpriteSheet = content.Load<Texture2D>(key); return true; /**** ** Content\Minigames ****/ - case "minigames\\clouds": // TitleMenu + case "minigames/clouds": // TitleMenu { if (Game1.activeClickableMenu is TitleMenu titleMenu) { @@ -455,128 +459,128 @@ namespace StardewModdingAPI.Metadata } return false; - case "minigames\\titlebuttons": // TitleMenu - return this.ReloadTitleButtons(content, key); + case "minigames/titlebuttons": // TitleMenu + return this.ReloadTitleButtons(content, assetName); /**** ** Content\Strings ****/ - case "strings\\stringsfromcsfiles": + case "strings/stringsfromcsfiles": return this.ReloadStringsFromCsFiles(content); /**** ** Content\TileSheets ****/ - case "tilesheets\\animations": // Game1.LoadContent + case "tilesheets/animations": // Game1.LoadContent Game1.animations = content.Load<Texture2D>(key); return true; - case "tilesheets\\buffsicons": // Game1.LoadContent + case "tilesheets/buffsicons": // Game1.LoadContent Game1.buffsIcons = content.Load<Texture2D>(key); return true; - case "tilesheets\\bushes": // new Bush() + case "tilesheets/bushes": // new Bush() Bush.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); return true; - case "tilesheets\\chairtiles": // Game1.LoadContent - return this.ReloadChairTiles(content, key, ignoreWorld); + case "tilesheets/chairtiles": // Game1.LoadContent + return this.ReloadChairTiles(content, assetName, ignoreWorld); - case "tilesheets\\craftables": // Game1.LoadContent + case "tilesheets/craftables": // Game1.LoadContent Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); return true; - case "tilesheets\\critters": // Critter constructor - return !ignoreWorld && this.ReloadCritterTextures(content, key) > 0; + case "tilesheets/critters": // Critter constructor + return !ignoreWorld && this.ReloadCritterTextures(content, assetName) > 0; - case "tilesheets\\crops": // Game1.LoadContent + case "tilesheets/crops": // Game1.LoadContent Game1.cropSpriteSheet = content.Load<Texture2D>(key); return true; - case "tilesheets\\debris": // Game1.LoadContent + case "tilesheets/debris": // Game1.LoadContent Game1.debrisSpriteSheet = content.Load<Texture2D>(key); return true; - case "tilesheets\\emotes": // Game1.LoadContent + case "tilesheets/emotes": // Game1.LoadContent Game1.emoteSpriteSheet = content.Load<Texture2D>(key); return true; - case "tilesheets\\fruittrees": // FruitTree + case "tilesheets/fruittrees": // FruitTree FruitTree.texture = content.Load<Texture2D>(key); return true; - case "tilesheets\\furniture": // Game1.LoadContent + case "tilesheets/furniture": // Game1.LoadContent Furniture.furnitureTexture = content.Load<Texture2D>(key); return true; - case "tilesheets\\furniturefront": // Game1.LoadContent + case "tilesheets/furniturefront": // Game1.LoadContent Furniture.furnitureFrontTexture = content.Load<Texture2D>(key); return true; - case "tilesheets\\projectiles": // Game1.LoadContent + case "tilesheets/projectiles": // Game1.LoadContent Projectile.projectileSheet = content.Load<Texture2D>(key); return true; - case "tilesheets\\rain": // Game1.LoadContent + case "tilesheets/rain": // Game1.LoadContent Game1.rainTexture = content.Load<Texture2D>(key); return true; - case "tilesheets\\tools": // Game1.ResetToolSpriteSheet + case "tilesheets/tools": // Game1.ResetToolSpriteSheet Game1.ResetToolSpriteSheet(); return true; - case "tilesheets\\weapons": // Game1.LoadContent + case "tilesheets/weapons": // Game1.LoadContent Tool.weaponsTexture = content.Load<Texture2D>(key); return true; /**** ** Content\TerrainFeatures ****/ - case "terrainfeatures\\flooring": // from Flooring + case "terrainfeatures/flooring": // from Flooring Flooring.floorsTexture = content.Load<Texture2D>(key); return true; - case "terrainfeatures\\flooring_winter": // from Flooring + case "terrainfeatures/flooring_winter": // from Flooring Flooring.floorsTextureWinter = content.Load<Texture2D>(key); return true; - case "terrainfeatures\\grass": // from Grass - return !ignoreWorld && this.ReloadGrassTextures(content, key); + case "terrainfeatures/grass": // from Grass + return !ignoreWorld && this.ReloadGrassTextures(content, assetName); - case "terrainfeatures\\hoedirt": // from HoeDirt + case "terrainfeatures/hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load<Texture2D>(key); return true; - case "terrainfeatures\\hoedirtdark": // from HoeDirt + case "terrainfeatures/hoedirtdark": // from HoeDirt HoeDirt.darkTexture = content.Load<Texture2D>(key); return true; - case "terrainfeatures\\hoedirtsnow": // from HoeDirt + case "terrainfeatures/hoedirtsnow": // from HoeDirt HoeDirt.snowTexture = content.Load<Texture2D>(key); return true; - case "terrainfeatures\\mushroom_tree": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.mushroomTree); + case "terrainfeatures/mushroom_tree": // from Tree + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.mushroomTree); - case "terrainfeatures\\tree_palm": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.palmTree); + case "terrainfeatures/tree_palm": // from Tree + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.palmTree); - case "terrainfeatures\\tree1_fall": // from Tree - case "terrainfeatures\\tree1_spring": // from Tree - case "terrainfeatures\\tree1_summer": // from Tree - case "terrainfeatures\\tree1_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.bushyTree); + case "terrainfeatures/tree1_fall": // from Tree + case "terrainfeatures/tree1_spring": // from Tree + case "terrainfeatures/tree1_summer": // from Tree + case "terrainfeatures/tree1_winter": // from Tree + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.bushyTree); - case "terrainfeatures\\tree2_fall": // from Tree - case "terrainfeatures\\tree2_spring": // from Tree - case "terrainfeatures\\tree2_summer": // from Tree - case "terrainfeatures\\tree2_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.leafyTree); + case "terrainfeatures/tree2_fall": // from Tree + case "terrainfeatures/tree2_spring": // from Tree + case "terrainfeatures/tree2_summer": // from Tree + case "terrainfeatures/tree2_winter": // from Tree + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.leafyTree); - case "terrainfeatures\\tree3_fall": // from Tree - case "terrainfeatures\\tree3_spring": // from Tree - case "terrainfeatures\\tree3_winter": // from Tree - return !ignoreWorld && this.ReloadTreeTextures(content, key, Tree.pineTree); + case "terrainfeatures/tree3_fall": // from Tree + case "terrainfeatures/tree3_spring": // from Tree + case "terrainfeatures/tree3_winter": // from Tree + return !ignoreWorld && this.ReloadTreeTextures(content, assetName, Tree.pineTree); } /**** @@ -585,25 +589,25 @@ namespace StardewModdingAPI.Metadata if (!ignoreWorld) { // dynamic textures - if (this.KeyStartsWith(key, "animals\\cat")) - return this.ReloadPetOrHorseSprites<Cat>(content, key); - if (this.KeyStartsWith(key, "animals\\dog")) - return this.ReloadPetOrHorseSprites<Dog>(content, key); - if (this.IsInFolder(key, "Animals")) - return this.ReloadFarmAnimalSprites(content, key); + if (assetName.StartsWith("animals/cat")) + return this.ReloadPetOrHorseSprites<Cat>(content, assetName); + if (assetName.StartsWith("animals/dog")) + return this.ReloadPetOrHorseSprites<Dog>(content, assetName); + if (assetName.IsDirectlyUnderPath("Animals")) + return this.ReloadFarmAnimalSprites(content, assetName); - if (this.IsInFolder(key, "Buildings")) - return this.ReloadBuildings(key); + if (assetName.IsDirectlyUnderPath("Buildings")) + return this.ReloadBuildings(assetName); - if (this.KeyStartsWith(key, "LooseSprites\\Fence")) - return this.ReloadFenceTextures(key); + if (assetName.StartsWith("LooseSprites/Fence")) + return this.ReloadFenceTextures(assetName); // dynamic data - if (this.IsInFolder(key, "Characters\\Dialogue")) - return this.ReloadNpcDialogue(key); + if (assetName.IsDirectlyUnderPath("Characters/Dialogue")) + return this.ReloadNpcDialogue(assetName); - if (this.IsInFolder(key, "Characters\\schedules")) - return this.ReloadNpcSchedules(key); + if (assetName.IsDirectlyUnderPath("Characters/schedules")) + return this.ReloadNpcSchedules(assetName); } return false; @@ -618,14 +622,14 @@ namespace StardewModdingAPI.Metadata ****/ /// <summary>Reload buttons on the title screen.</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="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> /// <remarks>Derived from the <see cref="TitleMenu"/> constructor and <see cref="TitleMenu.setUpIcons"/>.</remarks> - private bool ReloadTitleButtons(LocalizedContentManager content, string key) + private bool ReloadTitleButtons(LocalizedContentManager content, IAssetName assetName) { if (Game1.activeClickableMenu is TitleMenu titleMenu) { - Texture2D texture = content.Load<Texture2D>(key); + Texture2D texture = content.Load<Texture2D>(assetName.BaseName); titleMenu.titleButtonsTexture = texture; titleMenu.backButton.texture = texture; @@ -645,21 +649,21 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching pets or horses.</summary> /// <typeparam name="TAnimal">The animal type.</typeparam> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, string key) + private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, IAssetName assetName) where TAnimal : NPC { // find matches TAnimal[] animals = this.GetCharacters() .OfType<TAnimal>() - .Where(p => key == this.NormalizeAssetNameIgnoringEmpty(p.Sprite?.Texture?.Name)) + .Where(p => this.IsSameBaseName(assetName, p.Sprite?.Texture?.Name)) .ToArray(); if (!animals.Any()) return false; // update sprites - Texture2D texture = content.Load<Texture2D>(key); + Texture2D texture = content.Load<Texture2D>(assetName.BaseName); foreach (TAnimal animal in animals) animal.Sprite.spriteTexture = texture; return true; @@ -667,10 +671,10 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for matching farm animals.</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="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> /// <remarks>Derived from <see cref="FarmAnimal.reload"/>.</remarks> - private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key) + private bool ReloadFarmAnimalSprites(LocalizedContentManager content, IAssetName assetName) { // find matches FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); @@ -678,7 +682,7 @@ namespace StardewModdingAPI.Metadata return false; // update sprites - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.BaseName)); foreach (FarmAnimal animal in animals) { // get expected key @@ -687,26 +691,26 @@ namespace StardewModdingAPI.Metadata : animal.type.Value; if (animal.showDifferentTextureWhenReadyForHarvest.Value && animal.currentProduce.Value <= 0) expectedKey = $"Sheared{expectedKey}"; - expectedKey = $"Animals\\{expectedKey}"; + expectedKey = $"Animals/{expectedKey}"; // reload asset - if (expectedKey == key) + if (this.IsSameBaseName(assetName, expectedKey)) animal.Sprite.spriteTexture = texture.Value; } return texture.IsValueCreated; } /// <summary>Reload building textures.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadBuildings(string key) + private bool ReloadBuildings(IAssetName assetName) { // get paint mask info const string paintMaskSuffix = "_PaintMask"; - bool isPaintMask = key.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); + bool isPaintMask = assetName.BaseName.EndsWith(paintMaskSuffix, StringComparison.OrdinalIgnoreCase); // get building type - string type = Path.GetFileName(key); + string type = Path.GetFileName(assetName.BaseName); if (isPaintMask) type = type.Substring(0, type.Length - paintMaskSuffix.Length); @@ -718,7 +722,7 @@ namespace StardewModdingAPI.Metadata .ToArray(); // remove from paint mask cache - bool removedFromCache = this.RemoveFromPaintMaskCache(key); + bool removedFromCache = this.RemoveFromPaintMaskCache(assetName); // reload textures if (buildings.Any()) @@ -734,22 +738,20 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload map seat textures.</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="assetName">The asset name to reload.</param> /// <param name="ignoreWorld">Whether the in-game world is fully unloaded (e.g. on the title screen), so there's no need to propagate changes into the world.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadChairTiles(LocalizedContentManager content, string key, bool ignoreWorld) + private bool ReloadChairTiles(LocalizedContentManager content, IAssetName assetName, bool ignoreWorld) { - MapSeat.mapChairTexture = content.Load<Texture2D>(key); + MapSeat.mapChairTexture = content.Load<Texture2D>(assetName.BaseName); if (!ignoreWorld) { - foreach (var location in this.GetLocations()) + foreach (GameLocation location in this.GetLocations()) { foreach (MapSeat seat in location.mapSeats.Where(p => p != null)) { - string curKey = this.NormalizeAssetNameIgnoringEmpty(seat._loadedTextureFile); - - if (curKey == null || key.Equals(curKey, StringComparison.OrdinalIgnoreCase)) + if (this.IsSameBaseName(assetName, seat._loadedTextureFile)) seat.overlayTexture = MapSeat.mapChairTexture; } } @@ -760,9 +762,9 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload critter textures.</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="assetName">The asset name to reload.</param> /// <returns>Returns the number of reloaded assets.</returns> - private int ReloadCritterTextures(LocalizedContentManager content, string key) + private int ReloadCritterTextures(LocalizedContentManager content, IAssetName assetName) { // get critters Critter[] critters = @@ -770,7 +772,7 @@ namespace StardewModdingAPI.Metadata from location in this.GetLocations() where location.critters != null from Critter critter in location.critters - where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key + where this.IsSameBaseName(assetName, critter.sprite?.Texture?.Name) select critter ) .ToArray(); @@ -778,8 +780,8 @@ namespace StardewModdingAPI.Metadata return 0; // update sprites - Texture2D texture = content.Load<Texture2D>(key); - foreach (var entry in critters) + Texture2D texture = content.Load<Texture2D>(assetName.BaseName); + foreach (Critter entry in critters) entry.sprite.spriteTexture = texture; return critters.Length; @@ -787,28 +789,26 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload the sprites for interior doors.</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="assetName">The asset name to reload.</param> /// <returns>Returns whether any doors were affected.</returns> - private bool ReloadDoorSprites(LocalizedContentManager content, string key) + private bool ReloadDoorSprites(LocalizedContentManager content, IAssetName assetName) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.BaseName)); foreach (GameLocation location in this.GetLocations()) { - IEnumerable<InteriorDoor> doors = location.interiorDoors?.Doors; + IEnumerable<InteriorDoor?>? doors = location.interiorDoors?.Doors; if (doors == null) continue; - foreach (InteriorDoor door in doors) + foreach (InteriorDoor? door in doors) { if (door?.Sprite == null) continue; - string textureName = this.NormalizeAssetNameIgnoringEmpty(this.Reflection.GetField<string>(door.Sprite, "textureName").GetValue()); - if (textureName != key) - continue; - - door.Sprite.texture = texture.Value; + string? curKey = this.Reflection.GetField<string?>(door.Sprite, "textureName").GetValue(); + if (this.IsSameBaseName(assetName, curKey)) + door.Sprite.texture = texture.Value; } } @@ -831,12 +831,12 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the sprites for a fence type.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadFenceTextures(string key) + private bool ReloadFenceTextures(IAssetName assetName) { - // get fence type - if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) + // get fence type (e.g. LooseSprites/Fence3 => 3) + if (!int.TryParse(this.GetSegments(assetName.BaseName)[1].Substring("Fence".Length), out int fenceType)) return false; // get fences @@ -859,22 +859,22 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload tree textures.</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="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadGrassTextures(LocalizedContentManager content, string key) + private bool ReloadGrassTextures(LocalizedContentManager content, IAssetName assetName) { Grass[] grasses = ( from location in this.GetLocations() from grass in location.terrainFeatures.Values.OfType<Grass>() - where this.NormalizeAssetNameIgnoringEmpty(grass.textureName()) == key + where this.IsSameBaseName(assetName, grass.textureName()) select grass ) .ToArray(); if (grasses.Any()) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.BaseName)); foreach (Grass grass in grasses) grass.texture = texture; return true; @@ -931,16 +931,16 @@ namespace StardewModdingAPI.Metadata // warping onto the wrong tile (or even off-screen) if a patch changes the farmhouse // map on location change. if (playerPos.HasValue) - Game1.player.Position = playerPos.Value; + Game1.player!.Position = playerPos.Value; } /// <summary>Reload the disposition data 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="assetName">The asset name to reload.</param> /// <returns>Returns whether any NPCs were affected.</returns> - private bool ReloadNpcDispositions(LocalizedContentManager content, string key) + private bool ReloadNpcDispositions(LocalizedContentManager content, IAssetName assetName) { - IDictionary<string, string> data = content.Load<Dictionary<string, string>>(key); + IDictionary<string, string> data = content.Load<Dictionary<string, string>>(assetName.BaseName); bool changed = false; foreach (NPC npc in this.GetCharacters()) { @@ -955,18 +955,16 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the sprites for matching NPCs.</summary> - /// <param name="keys">The asset keys to reload.</param> - /// <param name="propagated">The asset keys which have been propagated.</param> - private void ReloadNpcSprites(IEnumerable<string> keys, IDictionary<string, bool> propagated) + /// <param name="propagated">The asset keys which are being propagated.</param> + private void ReloadNpcSprites(IDictionary<IAssetName, bool> propagated) { // get NPCs - HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() - let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name) - where key != null && lookup.Contains(key) - select new { Npc = npc, Key = key } + let key = this.ParseAssetNameOrNull(npc.Sprite?.Texture?.Name)?.GetBaseAssetName() + where key != null && propagated.ContainsKey(key) + select new { Npc = npc, AssetName = key } ) .ToArray(); if (!characters.Any()) @@ -975,63 +973,65 @@ namespace StardewModdingAPI.Metadata // update sprite foreach (var target in characters) { - target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.Key); - propagated[target.Key] = true; + target.Npc.Sprite.spriteTexture = this.LoadAndDisposeIfNeeded(target.Npc.Sprite.spriteTexture, target.AssetName.BaseName); + propagated[target.AssetName] = true; } } /// <summary>Reload the portraits for matching NPCs.</summary> - /// <param name="keys">The asset key to reload.</param> - /// <param name="propagated">The asset keys which have been propagated.</param> - private void ReloadNpcPortraits(IEnumerable<string> keys, IDictionary<string, bool> propagated) + /// <param name="propagated">The asset keys which are being propagated.</param> + private void ReloadNpcPortraits(IDictionary<IAssetName, bool> propagated) { // get NPCs - HashSet<string> lookup = new HashSet<string>(keys, StringComparer.OrdinalIgnoreCase); var characters = ( from npc in this.GetCharacters() where npc.isVillager() - let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name) - where key != null && lookup.Contains(key) - select new { Npc = npc, Key = key } + let key = this.ParseAssetNameOrNull(npc.Portrait?.Name)?.GetBaseAssetName() + where key != null && propagated.ContainsKey(key) + select new { Npc = npc, AssetName = key } ) .ToList(); // special case: Gil is a private NPC field on the AdventureGuild class (only used for the portrait) { - string gilKey = this.NormalizeAssetNameIgnoringEmpty("Portraits/Gil"); - if (lookup.Contains(gilKey)) + IAssetName gilKey = this.ParseAssetName("Portraits/Gil"); + if (propagated.ContainsKey(gilKey)) { GameLocation adventureGuild = Game1.getLocationFromName("AdventureGuild"); if (adventureGuild != null) - characters.Add(new { Npc = this.Reflection.GetField<NPC>(adventureGuild, "Gil").GetValue(), Key = gilKey }); + { + NPC? gil = this.Reflection.GetField<NPC?>(adventureGuild, "Gil").GetValue(); + if (gil != null) + characters.Add(new { Npc = gil, AssetName = gilKey }); + } } } // update portrait foreach (var target in characters) { - target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.Key); - propagated[target.Key] = true; + target.Npc.Portrait = this.LoadAndDisposeIfNeeded(target.Npc.Portrait, target.AssetName.BaseName); + propagated[target.AssetName] = true; } } /// <summary>Reload the sprites for matching players.</summary> - /// <param name="key">The asset key to reload.</param> - private bool ReloadPlayerSprites(string key) + /// <param name="assetName">The asset name to reload.</param> + private bool ReloadPlayerSprites(IAssetName assetName) { Farmer[] players = ( from player in Game1.getOnlineFarmers() - where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture()) + where this.IsSameBaseName(assetName, player.getTexture()) select player ) .ToArray(); foreach (Farmer player in players) { - this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture()); + this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>?>(typeof(FarmerRenderer), "_recolorOffsets").GetValue()?.Remove(player.getTexture()); player.FarmerRenderer.MarkSpriteDirty(); } @@ -1040,22 +1040,27 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload suspension bridge textures.</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="assetName">The asset name to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadSuspensionBridges(LocalizedContentManager content, string key) + private bool ReloadSuspensionBridges(LocalizedContentManager content, IAssetName assetName) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.BaseName)); foreach (GameLocation location in this.GetLocations(buildingInteriors: false)) { // get suspension bridges field - var field = this.Reflection.GetField<IEnumerable<SuspensionBridge>>(location, nameof(IslandNorth.suspensionBridges), required: false); + var field = this.Reflection.GetField<IEnumerable<SuspensionBridge>?>(location, nameof(IslandNorth.suspensionBridges), required: false); + // ReSharper disable once ConditionIsAlwaysTrueOrFalse -- field is nullable when required: false if (field == null || !typeof(IEnumerable<SuspensionBridge>).IsAssignableFrom(field.FieldInfo.FieldType)) continue; // update textures - foreach (SuspensionBridge bridge in field.GetValue()) - this.Reflection.GetField<Texture2D>(bridge, "_texture").SetValue(texture.Value); + IEnumerable<SuspensionBridge>? bridges = field.GetValue(); + if (bridges != null) + { + foreach (SuspensionBridge bridge in bridges) + this.Reflection.GetField<Texture2D>(bridge, "_texture").SetValue(texture.Value); + } } return texture.IsValueCreated; @@ -1063,10 +1068,10 @@ namespace StardewModdingAPI.Metadata /// <summary>Reload tree textures.</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="assetName">The asset name to reload.</param> /// <param name="type">The type to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) + private bool ReloadTreeTextures(LocalizedContentManager content, IAssetName assetName, int type) { Tree[] trees = this.GetLocations() .SelectMany(p => p.terrainFeatures.Values.OfType<Tree>()) @@ -1075,7 +1080,7 @@ namespace StardewModdingAPI.Metadata if (trees.Any()) { - Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(assetName.BaseName)); foreach (Tree tree in trees) tree.texture = texture; return true; @@ -1088,12 +1093,12 @@ namespace StardewModdingAPI.Metadata ** Reload data methods ****/ /// <summary>Reload the dialogue data for matching NPCs.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any assets were reloaded.</returns> - private bool ReloadNpcDialogue(string key) + private bool ReloadNpcDialogue(IAssetName assetName) { // get NPCs - string name = Path.GetFileName(key); + string name = Path.GetFileName(assetName.BaseName); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; @@ -1118,12 +1123,12 @@ namespace StardewModdingAPI.Metadata } /// <summary>Reload the schedules for matching NPCs.</summary> - /// <param name="key">The asset key to reload.</param> + /// <param name="assetName">The asset name to reload.</param> /// <returns>Returns whether any assets were reloaded.</returns> - private bool ReloadNpcSchedules(string key) + private bool ReloadNpcSchedules(IAssetName assetName) { // get NPCs - string name = Path.GetFileName(key); + string name = Path.GetFileName(assetName.BaseName); NPC[] villagers = this.GetCharacters().Where(npc => npc.Name == name && npc.isVillager()).ToArray(); if (!villagers.Any()) return false; @@ -1133,7 +1138,7 @@ namespace StardewModdingAPI.Metadata { // reload schedule this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false); - this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null); + this.Reflection.GetField<Dictionary<string, string>?>(villager, "_masterScheduleData").SetValue(null); villager.Schedule = villager.getSchedule(Game1.dayOfMonth); // switch to new schedule if needed @@ -1157,17 +1162,17 @@ namespace StardewModdingAPI.Metadata /// <remarks>Derived from the <see cref="Game1.TranslateFields"/>.</remarks> private bool ReloadStringsFromCsFiles(LocalizedContentManager content) { - Game1.samBandName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2156"); - Game1.elliottBookName = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.2157"); + Game1.samBandName = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.2156"); + Game1.elliottBookName = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.2157"); string[] dayNames = this.Reflection.GetField<string[]>(typeof(Game1), "_shortDayDisplayName").GetValue(); - dayNames[0] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3042"); - dayNames[1] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3043"); - dayNames[2] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3044"); - dayNames[3] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3045"); - dayNames[4] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3046"); - dayNames[5] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3047"); - dayNames[6] = content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3048"); + dayNames[0] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3042"); + dayNames[1] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3043"); + dayNames[2] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3044"); + dayNames[3] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3045"); + dayNames[4] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3046"); + dayNames[5] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3047"); + dayNames[6] = content.LoadString("Strings/StringsFromCSFiles:Game1.cs.3048"); return true; } @@ -1229,7 +1234,7 @@ namespace StardewModdingAPI.Metadata { foreach (Building building in buildableLocation.buildings) { - GameLocation indoors = building.indoors.Value; + GameLocation? indoors = building.indoors.Value; if (indoors != null) yield return new LocationInfo(indoors, building); } @@ -1237,58 +1242,52 @@ namespace StardewModdingAPI.Metadata } } - /// <summary>Normalize an asset key to match the cache key and assert that it's valid, but don't raise an error for null or empty values.</summary> - /// <param name="path">The asset key to normalize.</param> - private string NormalizeAssetNameIgnoringEmpty(string path) + /// <summary>Get whether two asset names are equivalent if you ignore the locale code.</summary> + /// <param name="left">The first value to compare.</param> + /// <param name="right">The second value to compare.</param> + private bool IsSameBaseName(IAssetName? left, string? right) { - if (string.IsNullOrWhiteSpace(path)) - return null; + if (left is null || right is null) + return false; - return this.AssertAndNormalizeAssetName(path); + IAssetName? parsedB = this.ParseAssetNameOrNull(right); + return this.IsSameBaseName(left, parsedB); } - /// <summary>Get whether a key starts with a substring after the substring is normalized.</summary> - /// <param name="key">The key to check.</param> - /// <param name="rawSubstring">The substring to normalize and find.</param> - private bool KeyStartsWith(string key, string rawSubstring) + /// <summary>Get whether two asset names are equivalent if you ignore the locale code.</summary> + /// <param name="left">The first value to compare.</param> + /// <param name="right">The second value to compare.</param> + private bool IsSameBaseName(IAssetName? left, IAssetName? right) { - if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring)) + if (left is null || right is null) return false; - return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.OrdinalIgnoreCase); + return left.IsEquivalentTo(right.BaseName, useBaseName: true); } - /// <summary>Get whether a normalized asset key is in the given folder.</summary> - /// <param name="key">The normalized asset key (like <c>Animals/cat</c>).</param> - /// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalized.</param> - /// <param name="allowSubfolders">Whether to return true if the key is inside a subfolder of the <paramref name="folder"/>.</param> - private bool IsInFolder(string key, string folder, bool allowSubfolders = false) + /// <summary>Normalize an asset key to match the cache key and assert that it's valid, but don't raise an error for null or empty values.</summary> + /// <param name="path">The asset key to normalize.</param> + private IAssetName? ParseAssetNameOrNull(string? path) { - return - this.KeyStartsWith(key, $"{folder}\\") - && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); + if (string.IsNullOrWhiteSpace(path)) + return null; + + return this.ParseAssetName(path); } /// <summary>Get the segments in a path (e.g. 'a/b' is 'a' and 'b').</summary> /// <param name="path">The path to check.</param> - private string[] GetSegments(string path) + private string[] GetSegments(string? path) { return path != null ? PathUtilities.GetSegments(path) - : new string[0]; - } - - /// <summary>Count the number of segments in a path (e.g. 'a/b' is 2).</summary> - /// <param name="path">The path to check.</param> - private int CountSegments(string path) - { - return this.GetSegments(path).Length; + : Array.Empty<string>(); } /// <summary>Load a texture, and dispose the old one if <see cref="AggressiveMemoryOptimizations"/> is enabled and it's different from the new instance.</summary> /// <param name="oldTexture">The previous texture to dispose.</param> /// <param name="key">The asset key to load.</param> - private Texture2D LoadAndDisposeIfNeeded(Texture2D oldTexture, string key) + private Texture2D LoadAndDisposeIfNeeded(Texture2D? oldTexture, string key) { // if aggressive memory optimizations are enabled, load the asset from the disposable // content manager and dispose the old instance if needed. @@ -1308,8 +1307,8 @@ namespace StardewModdingAPI.Metadata } /// <summary>Remove a case-insensitive key from the paint mask cache.</summary> - /// <param name="key">The paint mask asset key.</param> - private bool RemoveFromPaintMaskCache(string key) + /// <param name="assetName">The paint mask asset name.</param> + private bool RemoveFromPaintMaskCache(IAssetName assetName) { // make cache case-insensitive // This is needed for cache invalidation since mods may specify keys with a different capitalization @@ -1317,7 +1316,7 @@ namespace StardewModdingAPI.Metadata BuildingPainter.paintMaskLookup = new Dictionary<string, List<List<int>>>(BuildingPainter.paintMaskLookup, StringComparer.OrdinalIgnoreCase); // remove key from cache - return BuildingPainter.paintMaskLookup.Remove(key); + return BuildingPainter.paintMaskLookup.Remove(assetName.BaseName); } /// <summary>Metadata about a location used in asset propagation.</summary> @@ -1330,7 +1329,7 @@ namespace StardewModdingAPI.Metadata public GameLocation Location { get; } /// <summary>The building which contains the location, if any.</summary> - public Building ParentBuilding { get; } + public Building? ParentBuilding { get; } /********* @@ -1339,7 +1338,7 @@ namespace StardewModdingAPI.Metadata /// <summary>Construct an instance.</summary> /// <param name="location">The location instance.</param> /// <param name="parentBuilding">The building which contains the location, if any.</param> - public LocationInfo(GameLocation location, Building parentBuilding) + public LocationInfo(GameLocation location, Building? parentBuilding) { this.Location = location; this.ParentBuilding = parentBuilding; diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 367372b2..9b56f963 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -68,8 +68,8 @@ namespace StardewModdingAPI.Metadata ** detect code which may impact game stability ****/ yield return new TypeFinder("System.Runtime.CompilerServices.CallSite", InstructionHandleResult.DetectedDynamic); - yield return new FieldFinder(typeof(SaveGame).FullName, new[] { nameof(SaveGame.serializer), nameof(SaveGame.farmerSerializer), nameof(SaveGame.locationSerializer) }, InstructionHandleResult.DetectedSaveSerializer); - yield return new EventFinder(typeof(ISpecializedEvents).FullName, new[] { nameof(ISpecializedEvents.UnvalidatedUpdateTicked), nameof(ISpecializedEvents.UnvalidatedUpdateTicking) }, InstructionHandleResult.DetectedUnvalidatedUpdateTick); + yield return new FieldFinder(typeof(SaveGame).FullName!, new[] { nameof(SaveGame.serializer), nameof(SaveGame.farmerSerializer), nameof(SaveGame.locationSerializer) }, InstructionHandleResult.DetectedSaveSerializer); + yield return new EventFinder(typeof(ISpecializedEvents).FullName!, new[] { nameof(ISpecializedEvents.UnvalidatedUpdateTicked), nameof(ISpecializedEvents.UnvalidatedUpdateTicking) }, InstructionHandleResult.DetectedUnvalidatedUpdateTick); /**** ** detect paranoid issues @@ -77,23 +77,23 @@ namespace StardewModdingAPI.Metadata if (paranoidMode) { // filesystem access - yield return new TypeFinder(typeof(System.Console).FullName, InstructionHandleResult.DetectedConsoleAccess); + yield return new TypeFinder(typeof(System.Console).FullName!, InstructionHandleResult.DetectedConsoleAccess); yield return new TypeFinder( new[] { - typeof(System.IO.File).FullName, - typeof(System.IO.FileStream).FullName, - typeof(System.IO.FileInfo).FullName, - typeof(System.IO.Directory).FullName, - typeof(System.IO.DirectoryInfo).FullName, - typeof(System.IO.DriveInfo).FullName, - typeof(System.IO.FileSystemWatcher).FullName + typeof(System.IO.File).FullName!, + typeof(System.IO.FileStream).FullName!, + typeof(System.IO.FileInfo).FullName!, + typeof(System.IO.Directory).FullName!, + typeof(System.IO.DirectoryInfo).FullName!, + typeof(System.IO.DriveInfo).FullName!, + typeof(System.IO.FileSystemWatcher).FullName! }, InstructionHandleResult.DetectedFilesystemAccess ); // shell access - yield return new TypeFinder(typeof(System.Diagnostics.Process).FullName, InstructionHandleResult.DetectedShellAccess); + yield return new TypeFinder(typeof(System.Diagnostics.Process).FullName!, InstructionHandleResult.DetectedShellAccess); } } } diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs index 0e5be1c1..f764752b 100644 --- a/src/SMAPI/Mod.cs +++ b/src/SMAPI/Mod.cs @@ -8,30 +8,32 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>Provides simplified APIs for writing mods.</summary> - public IModHelper Helper { get; internal set; } + /// <inheritdoc /> + public IModHelper Helper { get; internal set; } = null!; - /// <summary>Writes messages to the console and log file.</summary> - public IMonitor Monitor { get; internal set; } + /// <inheritdoc /> + public IMonitor Monitor { get; internal set; } = null!; - /// <summary>The mod's manifest.</summary> - public IManifest ModManifest { get; internal set; } + /// <inheritdoc /> + public IManifest ModManifest { get; internal set; } = null!; /********* ** Public methods *********/ - /// <summary>The mod entry point, called after the mod is first loaded.</summary> - /// <param name="helper">Provides simplified APIs for writing mods.</param> + /// <inheritdoc /> public abstract void Entry(IModHelper helper); - /// <summary>Get an API that other mods can access. This is always called after <see cref="Entry"/>.</summary> - public virtual object GetApi() => null; + /// <inheritdoc /> + public virtual object? GetApi() + { + return null; + } /// <summary>Release or reset unmanaged resources.</summary> public void Dispose() { - (this.Helper as IDisposable)?.Dispose(); // deliberate do this outside overridable dispose method so mods don't accidentally suppress it + (this.Helper as IDisposable)?.Dispose(); // deliberately do this outside overridable dispose method so mods don't accidentally suppress it this.Dispose(true); GC.SuppressFinalize(this); } @@ -47,6 +49,7 @@ namespace StardewModdingAPI /// <summary>Destruct the instance.</summary> ~Mod() { + (this.Helper as IDisposable)?.Dispose(); // deliberately do this outside overridable dispose method so mods don't accidentally suppress it this.Dispose(false); } } diff --git a/src/SMAPI/Patches/Game1Patcher.cs b/src/SMAPI/Patches/Game1Patcher.cs index 173a2055..8f806790 100644 --- a/src/SMAPI/Patches/Game1Patcher.cs +++ b/src/SMAPI/Patches/Game1Patcher.cs @@ -20,10 +20,10 @@ namespace StardewModdingAPI.Patches ** Fields *********/ /// <summary>Simplifies access to private code.</summary> - private static Reflector Reflection; + private static Reflector Reflection = null!; // initialized in constructor /// <summary>A callback to invoke when the load stage changes.</summary> - private static Action<LoadStage> OnStageChanged; + private static Action<LoadStage> OnStageChanged = null!; // initialized in constructor /// <summary>Whether the game is running running the code in <see cref="Game1.loadForNewGame"/>.</summary> private static bool IsInLoadForNewGame; diff --git a/src/SMAPI/Patches/TitleMenuPatcher.cs b/src/SMAPI/Patches/TitleMenuPatcher.cs index b4320ce0..18f1a830 100644 --- a/src/SMAPI/Patches/TitleMenuPatcher.cs +++ b/src/SMAPI/Patches/TitleMenuPatcher.cs @@ -17,7 +17,7 @@ namespace StardewModdingAPI.Patches ** Fields *********/ /// <summary>A callback to invoke when the load stage changes.</summary> - private static Action<LoadStage> OnStageChanged; + private static Action<LoadStage> OnStageChanged = null!; // initialized in constructor /********* diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 0c90f2aa..a6861bca 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -20,7 +20,7 @@ namespace StardewModdingAPI private static readonly string DllSearchPath = EarlyConstants.InternalFilesPath; /// <summary>The assembly paths in the search folders indexed by assembly name.</summary> - private static Dictionary<string, string> AssemblyPathsByName; + private static Dictionary<string, string>? AssemblyPathsByName; /********* @@ -59,26 +59,26 @@ namespace StardewModdingAPI /// <summary>Method called when assembly resolution fails, which may return a manually resolved assembly.</summary> /// <param name="sender">The event sender.</param> /// <param name="e">The event arguments.</param> - private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs e) + private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs e) { // cache assembly paths by name if (Program.AssemblyPathsByName == null) { Program.AssemblyPathsByName = new(StringComparer.OrdinalIgnoreCase); - foreach (string searchPath in new[] { EarlyConstants.ExecutionPath, Program.DllSearchPath }) + foreach (string searchPath in new[] { EarlyConstants.GamePath, Program.DllSearchPath }) { foreach (string dllPath in Directory.EnumerateFiles(searchPath, "*.dll")) { try { - string curName = AssemblyName.GetAssemblyName(dllPath).Name; + string? curName = AssemblyName.GetAssemblyName(dllPath).Name; if (curName != null) Program.AssemblyPathsByName[curName] = dllPath; } catch { - continue; + // ignore invalid DLL } } } @@ -87,8 +87,8 @@ namespace StardewModdingAPI // resolve try { - string searchName = new AssemblyName(e.Name).Name; - return searchName != null && Program.AssemblyPathsByName.TryGetValue(searchName, out string assemblyPath) + string? searchName = new AssemblyName(e.Name).Name; + return searchName != null && Program.AssemblyPathsByName.TryGetValue(searchName, out string? assemblyPath) ? Assembly.LoadFrom(assemblyPath) : null; } @@ -110,7 +110,7 @@ namespace StardewModdingAPI catch (Exception ex) { // file doesn't exist - if (!File.Exists(Path.Combine(EarlyConstants.ExecutionPath, $"{EarlyConstants.GameAssemblyName}.exe"))) + if (!File.Exists(Path.Combine(EarlyConstants.GamePath, $"{EarlyConstants.GameAssemblyName}.exe"))) Program.PrintErrorAndExit("Oops! SMAPI can't find the game. Make sure you're running StardewModdingAPI.exe in your game folder."); // can't load file @@ -127,7 +127,7 @@ namespace StardewModdingAPI // min version if (Constants.GameVersion.IsOlderThan(Constants.MinimumGameVersion)) { - ISemanticVersion suggestedApiVersion = Constants.GetCompatibleApiVersion(Constants.GameVersion); + ISemanticVersion? suggestedApiVersion = Constants.GetCompatibleApiVersion(Constants.GameVersion); Program.PrintErrorAndExit(suggestedApiVersion != null ? $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. You can install SMAPI {suggestedApiVersion} instead to fix this error, or update your game to the latest version." : $"Oops! You're running Stardew Valley {Constants.GameVersion}, but the oldest supported version is {Constants.MinimumGameVersion}. Please update your game before using SMAPI." @@ -150,7 +150,7 @@ namespace StardewModdingAPI foreach (var type in new[] { typeof(IManifest), typeof(Manifest) }) { AssemblyName assemblyName = type.Assembly.GetName(); - ISemanticVersion assemblyVersion = new SemanticVersion(assemblyName.Version); + ISemanticVersion assemblyVersion = new SemanticVersion(assemblyName.Version!); if (!assemblyVersion.Equals(smapiVersion)) Program.PrintErrorAndExit($"Oops! The 'smapi-internal/{assemblyName.Name}.dll' file is version {assemblyVersion} instead of the required {Constants.ApiVersion}. SMAPI doesn't seem to be installed correctly."); } @@ -160,8 +160,8 @@ namespace StardewModdingAPI /// <remarks>This is needed to resolve native DLLs like libSkiaSharp.</remarks> private static void AssertDepsJson() { - string sourcePath = Path.Combine(Constants.ExecutionPath, "Stardew Valley.deps.json"); - string targetPath = Path.Combine(Constants.ExecutionPath, "StardewModdingAPI.deps.json"); + string sourcePath = Path.Combine(Constants.GamePath, "Stardew Valley.deps.json"); + string targetPath = Path.Combine(Constants.GamePath, "StardewModdingAPI.deps.json"); if (!File.Exists(targetPath) || FileUtilities.GetFileHash(sourcePath) != FileUtilities.GetFileHash(targetPath)) { @@ -179,34 +179,47 @@ namespace StardewModdingAPI bool writeToConsole = !args.Contains("--no-terminal") && Environment.GetEnvironmentVariable("SMAPI_NO_TERMINAL") == null; // get mods path + bool? developerMode = null; string modsPath; { - string rawModsPath = null; + string? rawModsPath = null; - // get from command line args + // get mods path from command line args int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; if (pathIndex >= 1 && args.Length >= pathIndex) rawModsPath = args[pathIndex]; + // get developer mode from command line args + if (args.Contains("--developer-mode")) + developerMode = true; + if (args.Contains("--developer-mode-off")) + developerMode = false; + // get from environment variables if (string.IsNullOrWhiteSpace(rawModsPath)) rawModsPath = Environment.GetEnvironmentVariable("SMAPI_MODS_PATH"); + if (developerMode is null) + { + string? rawDeveloperMode = Environment.GetEnvironmentVariable("SMAPI_DEVELOPER_MODE"); + if (rawDeveloperMode != null) + developerMode = bool.Parse(rawDeveloperMode); + } - // normalise + // normalize modsPath = !string.IsNullOrWhiteSpace(rawModsPath) - ? Path.Combine(Constants.ExecutionPath, rawModsPath) + ? Path.Combine(Constants.GamePath, rawModsPath) : Constants.DefaultModsPath; } // load SMAPI - using SCore core = new SCore(modsPath, writeToConsole); + using SCore core = new(modsPath, writeToConsole, developerMode); core.RunInteractively(); } /// <summary>Write an error directly to the console and exit.</summary> /// <param name="message">The error message to display.</param> /// <param name="technicalMessage">An additional message to log with technical details.</param> - private static void PrintErrorAndExit(string message, string technicalMessage = null) + private static void PrintErrorAndExit(string message, string? technicalMessage = null) { Console.ForegroundColor = ConsoleColor.Red; Console.WriteLine(message); diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs index ee8a1674..daa58943 100644 --- a/src/SMAPI/Properties/AssemblyInfo.cs +++ b/src/SMAPI/Properties/AssemblyInfo.cs @@ -2,3 +2,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("SMAPI.Tests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing +[assembly: InternalsVisibleTo("ContentPatcher")] diff --git a/src/SMAPI/SButton.cs b/src/SMAPI/SButton.cs index ae825696..133ea901 100644 --- a/src/SMAPI/SButton.cs +++ b/src/SMAPI/SButton.cs @@ -674,7 +674,7 @@ namespace StardewModdingAPI } // mouse - if (input == SButton.MouseLeft || input == SButton.MouseRight) + if (input is SButton.MouseLeft or SButton.MouseRight) { button = new InputButton(mouseLeft: input == SButton.MouseLeft); return true; diff --git a/src/SMAPI/SButtonState.cs b/src/SMAPI/SButtonState.cs index 5f3e8d3c..ca2dd83d 100644 --- a/src/SMAPI/SButtonState.cs +++ b/src/SMAPI/SButtonState.cs @@ -23,7 +23,7 @@ namespace StardewModdingAPI /// <param name="state">The button state.</param> public static bool IsDown(this SButtonState state) { - return state == SButtonState.Held || state == SButtonState.Pressed; + return state is SButtonState.Held or SButtonState.Pressed; } } } diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json index e62c8880..065dfa8c 100644 --- a/src/SMAPI/SMAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -47,6 +47,12 @@ copy all the settings, or you may cause bugs due to overridden changes in future "AggressiveMemoryOptimizations": false, /** + * Whether to use the experimental Pintail API proxying library, instead of the original + * proxying built into SMAPI itself. + */ + "UsePintail": true, + + /** * Whether to add a section to the 'mod issues' list for mods which directly use potentially * sensitive .NET APIs like file or shell access. Note that many mods do this legitimately as * part of their normal functionality, so these warnings are meaningless without further @@ -70,11 +76,6 @@ copy all the settings, or you may cause bugs due to overridden changes in future "GitHubProjectName": "Pathoschild/SMAPI", /** - * Stardew64Installer's GitHub project name, used to perform update checks. - */ - "Stardew64InstallerGitHubProjectName": "Steviegt6/Stardew64Installer", - - /** * The base URL for SMAPI's web API, used to perform update checks. */ "WebApiBaseUrl": "https://smapi.io/api/", diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index f07ede87..27044679 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -25,6 +25,7 @@ <PackageReference Include="Mono.Cecil" Version="0.11.4" /> <PackageReference Include="MonoMod.Common" Version="21.6.21.1" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + <PackageReference Include="Pintail" Version="2.1.0" /> <PackageReference Include="Platonymous.TMXTile" Version="1.5.9" /> <PackageReference Include="System.Reflection.Emit" Version="4.7.0" /> <PackageReference Include="System.Runtime.Caching" Version="5.0.0" /> diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index ae616419..81e526e7 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; namespace StardewModdingAPI @@ -26,10 +27,10 @@ namespace StardewModdingAPI public int PatchVersion => this.Version.PatchVersion; /// <inheritdoc /> - public string PrereleaseTag => this.Version.PrereleaseTag; + public string? PrereleaseTag => this.Version.PrereleaseTag; /// <inheritdoc /> - public string BuildMetadata => this.Version.BuildMetadata; + public string? BuildMetadata => this.Version.BuildMetadata; /********* @@ -41,7 +42,7 @@ namespace StardewModdingAPI /// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> - public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null) + public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string? prereleaseTag = null, string? buildMetadata = null) : this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { } /// <summary>Construct an instance.</summary> @@ -52,7 +53,7 @@ namespace StardewModdingAPI /// <param name="platformRelease">The platform-specific version (if applicable).</param> /// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param> [JsonConstructor] - internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null) + internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string? prereleaseTag = null, string? buildMetadata = null) : this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { } /// <summary>Construct an instance.</summary> @@ -84,6 +85,9 @@ namespace StardewModdingAPI } /// <inheritdoc /> +#if NET5_0_OR_GREATER + [MemberNotNullWhen(true, nameof(SemanticVersion.PrereleaseTag))] +#endif public bool IsPrerelease() { return this.Version.IsPrerelease(); @@ -91,49 +95,49 @@ namespace StardewModdingAPI /// <inheritdoc /> /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks> - public int CompareTo(ISemanticVersion other) + public int CompareTo(ISemanticVersion? other) { return this.Version.CompareTo(other); } /// <inheritdoc /> - public bool IsOlderThan(ISemanticVersion other) + public bool IsOlderThan(ISemanticVersion? other) { return this.Version.IsOlderThan(other); } /// <inheritdoc /> - public bool IsOlderThan(string other) + public bool IsOlderThan(string? other) { return this.Version.IsOlderThan(other); } /// <inheritdoc /> - public bool IsNewerThan(ISemanticVersion other) + public bool IsNewerThan(ISemanticVersion? other) { return this.Version.IsNewerThan(other); } /// <inheritdoc /> - public bool IsNewerThan(string other) + public bool IsNewerThan(string? other) { return this.Version.IsNewerThan(other); } /// <inheritdoc /> - public bool IsBetween(ISemanticVersion min, ISemanticVersion max) + public bool IsBetween(ISemanticVersion? min, ISemanticVersion? max) { return this.Version.IsBetween(min, max); } /// <inheritdoc /> - public bool IsBetween(string min, string max) + public bool IsBetween(string? min, string? max) { return this.Version.IsBetween(min, max); } /// <inheritdoc /> - public bool Equals(ISemanticVersion other) + public bool Equals(ISemanticVersion? other) { return other != null && this.CompareTo(other) == 0; } @@ -154,9 +158,9 @@ namespace StardewModdingAPI /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> - public static bool TryParse(string version, out ISemanticVersion parsed) + public static bool TryParse(string version, [NotNullWhen(true)] out ISemanticVersion? parsed) { - if (Toolkit.SemanticVersion.TryParse(version, out ISemanticVersion versionImpl)) + if (Toolkit.SemanticVersion.TryParse(version, out ISemanticVersion? versionImpl)) { parsed = new SemanticVersion(versionImpl); return true; diff --git a/src/SMAPI/Translation.cs b/src/SMAPI/Translation.cs index 149f6728..01cb92b2 100644 --- a/src/SMAPI/Translation.cs +++ b/src/SMAPI/Translation.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Reflection; using System.Text.RegularExpressions; @@ -15,14 +16,14 @@ namespace StardewModdingAPI /// <summary>The placeholder text when the translation is <c>null</c> or empty, where <c>{0}</c> is the translation key.</summary> internal const string PlaceholderText = "(no translation:{0})"; - /// <summary>The locale for which the translation was fetched.</summary> + /// <summary>The locale for which the translation was fetched like <c>fr-FR</c>, or an empty string for English.</summary> private readonly string Locale; /// <summary>The underlying translation text.</summary> - private readonly string Text; + private readonly string? Text; /// <summary>The value to return if the translations is undefined.</summary> - private readonly string Placeholder; + private readonly string? Placeholder; /********* @@ -39,12 +40,12 @@ namespace StardewModdingAPI /// <param name="locale">The locale for which the translation was fetched.</param> /// <param name="key">The translation key.</param> /// <param name="text">The underlying translation text.</param> - internal Translation(string locale, string key, string text) + internal Translation(string locale, string key, string? text) : this(locale, key, text, string.Format(Translation.PlaceholderText, key)) { } /// <summary>Replace the text if it's <c>null</c> or empty. If you set a <c>null</c> or empty value, the translation will show the fallback "no translation" placeholder (see <see cref="UsePlaceholder"/> if you want to disable that). Returns a new instance if changed.</summary> /// <param name="default">The default value.</param> - public Translation Default(string @default) + public Translation Default(string? @default) { return this.HasValue() ? this @@ -52,7 +53,7 @@ namespace StardewModdingAPI } /// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty. Returns a new instance.</summary> - /// <param name="use">Whether to return a placeholder.</param> + /// <param name="use">Whether to return a placeholder. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the text non-nullable.</strong></param> public Translation UsePlaceholder(bool use) { return new Translation(this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, this.Key) : null); @@ -61,20 +62,20 @@ namespace StardewModdingAPI /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> /// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception> - public Translation Tokens(object tokens) + public Translation Tokens(object? tokens) { if (string.IsNullOrWhiteSpace(this.Text) || tokens == null) return this; // get dictionary of tokens - IDictionary<string, string> tokenLookup = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + Dictionary<string, string?> tokenLookup = new(StringComparer.OrdinalIgnoreCase); { // from dictionary if (tokens is IDictionary inputLookup) { foreach (DictionaryEntry entry in inputLookup) { - string key = entry.Key?.ToString().Trim(); + string? key = entry.Key.ToString()?.Trim(); if (key != null) tokenLookup[key] = entry.Value?.ToString(); } @@ -95,8 +96,8 @@ namespace StardewModdingAPI string text = Regex.Replace(this.Text, @"{{([ \w\.\-]+)}}", match => { string key = match.Groups[1].Value.Trim(); - return tokenLookup.TryGetValue(key, out string value) - ? value + return tokenLookup.TryGetValue(key, out string? value) + ? (value ?? "") : match.Value; }); return new Translation(this.Locale, this.Key, text); @@ -109,18 +110,21 @@ namespace StardewModdingAPI } /// <summary>Get the translation text. Calling this method isn't strictly necessary, since you can assign a <see cref="Translation"/> value directly to a string.</summary> + /// <remarks><strong>Limitation with nullable reference types: if there's no text and you disabled the fallback via <see cref="UsePlaceholder"/>, this will return null but the return value will still be marked non-nullable.</strong></remarks> public override string ToString() { return this.Placeholder != null && !this.HasValue() ? this.Placeholder - : this.Text; + : this.Text!; } /// <summary>Get a string representation of the given translation.</summary> /// <param name="translation">The translation key.</param> + /// <remarks><strong>Limitation with nullable reference types: if there's no text and you disabled the fallback via <see cref="UsePlaceholder"/>, this will return null but the return value will still be marked non-nullable.</strong></remarks> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The null check is required due to limitations in nullable type annotations (see remarks).")] public static implicit operator string(Translation translation) { - return translation?.ToString(); + return translation?.ToString()!; } @@ -132,7 +136,7 @@ namespace StardewModdingAPI /// <param name="key">The translation key.</param> /// <param name="text">The underlying translation text.</param> /// <param name="placeholder">The value to return if the translations is undefined.</param> - private Translation(string locale, string key, string text, string placeholder) + private Translation(string locale, string key, string? text, string? placeholder) { this.Locale = locale; this.Key = key; diff --git a/src/SMAPI/Utilities/Keybind.cs b/src/SMAPI/Utilities/Keybind.cs index 87b867a9..3455ce77 100644 --- a/src/SMAPI/Utilities/Keybind.cs +++ b/src/SMAPI/Utilities/Keybind.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Framework; @@ -42,13 +43,13 @@ namespace StardewModdingAPI.Utilities /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> /// <param name="parsed">The parsed keybind, if valid.</param> /// <param name="errors">The parse errors, if any.</param> - public static bool TryParse(string input, out Keybind parsed, out string[] errors) + public static bool TryParse(string input, [NotNullWhen(true)] out Keybind? parsed, out string[] errors) { // empty input if (string.IsNullOrWhiteSpace(input)) { parsed = new Keybind(SButton.None); - errors = new string[0]; + errors = Array.Empty<string>(); return true; } @@ -97,7 +98,7 @@ namespace StardewModdingAPI.Utilities else { parsed = new Keybind(buttons); - errors = new string[0]; + errors = Array.Empty<string>(); return true; } } @@ -118,11 +119,11 @@ namespace StardewModdingAPI.Utilities return SButtonState.None; // mix of held + pressed => pressed - if (states.All(p => p == SButtonState.Pressed || p == SButtonState.Held)) + if (states.All(p => p is SButtonState.Pressed or SButtonState.Held)) return SButtonState.Pressed; // mix of held + released => released - if (states.All(p => p == SButtonState.Held || p == SButtonState.Released)) + if (states.All(p => p is SButtonState.Held or SButtonState.Released)) return SButtonState.Released; // not down last tick or now diff --git a/src/SMAPI/Utilities/KeybindList.cs b/src/SMAPI/Utilities/KeybindList.cs index 28cae240..aa12a37a 100644 --- a/src/SMAPI/Utilities/KeybindList.cs +++ b/src/SMAPI/Utilities/KeybindList.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using StardewModdingAPI.Toolkit.Serialization; @@ -40,7 +41,7 @@ namespace StardewModdingAPI.Utilities /// <exception cref="FormatException">The <paramref name="input"/> format is invalid.</exception> public static KeybindList Parse(string input) { - return KeybindList.TryParse(input, out KeybindList parsed, out string[] errors) + return KeybindList.TryParse(input, out KeybindList? parsed, out string[] errors) ? parsed : throw new SParseException($"Can't parse {nameof(Keybind)} from invalid value '{input}'.\n{string.Join("\n", errors)}"); } @@ -49,13 +50,13 @@ namespace StardewModdingAPI.Utilities /// <param name="input">The keybind string. See remarks on <see cref="ToString"/> for format details.</param> /// <param name="parsed">The parsed keybind list, if valid.</param> /// <param name="errors">The errors that occurred while parsing the input, if any.</param> - public static bool TryParse(string input, out KeybindList parsed, out string[] errors) + public static bool TryParse(string? input, [NotNullWhen(true)] out KeybindList? parsed, out string[] errors) { // empty input if (string.IsNullOrWhiteSpace(input)) { parsed = new KeybindList(); - errors = new string[0]; + errors = Array.Empty<string>(); return true; } @@ -67,7 +68,7 @@ namespace StardewModdingAPI.Utilities if (string.IsNullOrWhiteSpace(rawSet)) continue; - if (!Keybind.TryParse(rawSet, out Keybind keybind, out string[] curErrors)) + if (!Keybind.TryParse(rawSet, out Keybind? keybind, out string[] curErrors)) rawErrors.AddRange(curErrors); else keybinds.Add(keybind); @@ -83,7 +84,7 @@ namespace StardewModdingAPI.Utilities else { parsed = new KeybindList(keybinds.ToArray()); - errors = new string[0]; + errors = Array.Empty<string>(); return true; } } @@ -139,7 +140,7 @@ namespace StardewModdingAPI.Utilities public bool IsDown() { SButtonState state = this.GetState(); - return state == SButtonState.Pressed || state == SButtonState.Held; + return state is SButtonState.Pressed or SButtonState.Held; } /// <summary>Get whether the input binding was just pressed this tick.</summary> @@ -149,7 +150,7 @@ namespace StardewModdingAPI.Utilities } /// <summary>Get the keybind which is currently down, if any. If there are multiple keybinds down, the first one is returned.</summary> - public Keybind GetKeybindCurrentlyDown() + public Keybind? GetKeybindCurrentlyDown() { return this.Keybinds.FirstOrDefault(p => p.GetState().IsDown()); } diff --git a/src/SMAPI/Utilities/PathUtilities.cs b/src/SMAPI/Utilities/PathUtilities.cs index 541b163c..4350f441 100644 --- a/src/SMAPI/Utilities/PathUtilities.cs +++ b/src/SMAPI/Utilities/PathUtilities.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using ToolkitPathUtilities = StardewModdingAPI.Toolkit.Utilities.PathUtilities; @@ -20,14 +21,16 @@ namespace StardewModdingAPI.Utilities /// <param name="path">The path to split.</param> /// <param name="limit">The number of segments to match. Any additional segments will be merged into the last returned part.</param> [Pure] - public static string[] GetSegments(string path, int? limit = null) + public static string[] GetSegments(string? path, int? limit = null) { return ToolkitPathUtilities.GetSegments(path, limit); } /// <summary>Normalize an asset name to match how MonoGame's content APIs would normalize and cache it.</summary> /// <param name="assetName">The asset name to normalize.</param> - public static string NormalizeAssetName(string assetName) + [Pure] + [return: NotNullIfNotNull("assetName")] + public static string? NormalizeAssetName(string? assetName) { return ToolkitPathUtilities.NormalizeAssetName(assetName); } @@ -36,7 +39,8 @@ namespace StardewModdingAPI.Utilities /// <param name="path">The file path to normalize.</param> /// <remarks>This should only be used for file paths. For asset names, use <see cref="NormalizeAssetName"/> instead.</remarks> [Pure] - public static string NormalizePath(string path) + [return: NotNullIfNotNull("path")] + public static string? NormalizePath(string? path) { return ToolkitPathUtilities.NormalizePath(path); } @@ -44,7 +48,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether a path is relative and doesn't try to climb out of its containing folder (e.g. doesn't contain <c>../</c>).</summary> /// <param name="path">The path to check.</param> [Pure] - public static bool IsSafeRelativePath(string path) + public static bool IsSafeRelativePath(string? path) { return ToolkitPathUtilities.IsSafeRelativePath(path); } @@ -52,7 +56,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether a string is a valid 'slug', containing only basic characters that are safe in all contexts (e.g. filenames, URLs, etc).</summary> /// <param name="str">The string to check.</param> [Pure] - public static bool IsSlug(string str) + public static bool IsSlug(string? str) { return ToolkitPathUtilities.IsSlug(str); } diff --git a/src/SMAPI/Utilities/PerScreen.cs b/src/SMAPI/Utilities/PerScreen.cs index 20b8fbce..1c4c56fe 100644 --- a/src/SMAPI/Utilities/PerScreen.cs +++ b/src/SMAPI/Utilities/PerScreen.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using StardewModdingAPI.Framework; +using StardewModdingAPI.Framework.Deprecations; namespace StardewModdingAPI.Utilities { @@ -37,15 +39,14 @@ namespace StardewModdingAPI.Utilities ** Public methods *********/ /// <summary>Construct an instance.</summary> + /// <remarks><strong>Limitation with nullable reference types:</strong> when the underlying type <typeparamref name="T"/> is nullable, this sets the default value to null regardless of whether you marked the type parameter nullable. To avoid that, set the default value with the 'createNewState' argument instead.</remarks> public PerScreen() - : this(null) { } + : this(null, nullExpected: true) { } /// <summary>Construct an instance.</summary> /// <param name="createNewState">Create the initial state for a screen.</param> public PerScreen(Func<T> createNewState) - { - this.CreateNewState = createNewState ?? (() => default); - } + : this(createNewState, nullExpected: false) { } /// <summary>Get all active values by screen ID. This doesn't initialize the value for a screen ID if it's not created yet.</summary> public IEnumerable<KeyValuePair<int, T>> GetActiveValues() @@ -59,7 +60,7 @@ namespace StardewModdingAPI.Utilities public T GetValueForScreen(int screenId) { this.RemoveDeadScreens(); - return this.States.TryGetValue(screenId, out T state) + return this.States.TryGetValue(screenId, out T? state) ? state : this.States[screenId] = this.CreateNewState(); } @@ -76,13 +77,37 @@ namespace StardewModdingAPI.Utilities /// <summary>Remove all active values.</summary> public void ResetAllScreens() { - this.RemoveScreens(p => true); + this.RemoveScreens(_ => true); } /********* ** Private methods *********/ + /// <summary>Construct an instance.</summary> + /// <param name="createNewState">Create the initial state for a screen.</param> + /// <param name="nullExpected">Whether a null <paramref name="createNewState"/> value is expected.</param> + /// <remarks>This constructor only exists to maintain backwards compatibility. In SMAPI 4.0.0, the overload that passes <c>nullExpected: false</c> should throw an exception instead.</remarks> + private PerScreen(Func<T>? createNewState, bool nullExpected) + { + if (createNewState is null) + { + createNewState = (() => default!); + + if (!nullExpected) + { + SCore.DeprecationManager.Warn( + SCore.DeprecationManager.GetModFromStack(), + $"calling the {nameof(PerScreen<T>)} constructor with null", + "3.14.0", + DeprecationLevel.Notice + ); + } + } + + this.CreateNewState = createNewState; + } + /// <summary>Remove screens which are no longer active.</summary> private void RemoveDeadScreens() { diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index cd075dcc..1d4e4489 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Linq; using Newtonsoft.Json; using StardewModdingAPI.Framework; @@ -22,7 +23,7 @@ namespace StardewModdingAPI.Utilities private readonly int DaysInSeason = 28; /// <summary>The core SMAPI translations.</summary> - internal static Translator Translations; + internal static Translator? Translations; /********* @@ -92,7 +93,8 @@ namespace StardewModdingAPI.Utilities /// <summary>Get a date from a game date instance.</summary> /// <param name="date">The world date.</param> - public static SDate From(WorldDate date) + [return: NotNullIfNotNull("date")] + public static SDate? From(WorldDate? date) { if (date == null) return null; @@ -168,14 +170,14 @@ namespace StardewModdingAPI.Utilities ****/ /// <summary>Get whether this instance is equal to another.</summary> /// <param name="other">The other value to compare.</param> - public bool Equals(SDate other) + public bool Equals(SDate? other) { return this == other; } /// <summary>Get whether this instance is equal to another.</summary> /// <param name="obj">The other value to compare.</param> - public override bool Equals(object obj) + public override bool Equals(object? obj) { return obj is SDate other && this == other; } @@ -193,7 +195,7 @@ namespace StardewModdingAPI.Utilities /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> /// <returns>The equality of the dates</returns> - public static bool operator ==(SDate date, SDate other) + public static bool operator ==(SDate? date, SDate? other) { return date?.DaysSinceStart == other?.DaysSinceStart; } @@ -201,7 +203,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether one date is not equal to another.</summary> /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> - public static bool operator !=(SDate date, SDate other) + public static bool operator !=(SDate? date, SDate? other) { return date?.DaysSinceStart != other?.DaysSinceStart; } @@ -209,7 +211,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether one date is more than another.</summary> /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> - public static bool operator >(SDate date, SDate other) + public static bool operator >(SDate? date, SDate? other) { return date?.DaysSinceStart > other?.DaysSinceStart; } @@ -217,7 +219,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether one date is more than or equal to another.</summary> /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> - public static bool operator >=(SDate date, SDate other) + public static bool operator >=(SDate? date, SDate? other) { return date?.DaysSinceStart >= other?.DaysSinceStart; } @@ -225,7 +227,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether one date is less than or equal to another.</summary> /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> - public static bool operator <=(SDate date, SDate other) + public static bool operator <=(SDate? date, SDate? other) { return date?.DaysSinceStart <= other?.DaysSinceStart; } @@ -233,7 +235,7 @@ namespace StardewModdingAPI.Utilities /// <summary>Get whether one date is less than another.</summary> /// <param name="date">The base date to compare.</param> /// <param name="other">The other date to compare.</param> - public static bool operator <(SDate date, SDate other) + public static bool operator <(SDate? date, SDate? other) { return date?.DaysSinceStart < other?.DaysSinceStart; } @@ -248,8 +250,11 @@ namespace StardewModdingAPI.Utilities /// <param name="year">The year.</param> /// <param name="allowDayZero">Whether to allow 0 spring Y1 as a valid date.</param> /// <exception cref="ArgumentException">One of the arguments has an invalid value (like day 35).</exception> + [SuppressMessage("ReSharper", "ConstantConditionalAccessQualifier", Justification = "The nullability is validated in this constructor.")] private SDate(int day, string season, int year, bool allowDayZero) { + season = season?.Trim().ToLowerInvariant()!; // null-checked below + // validate if (season == null) throw new ArgumentNullException(nameof(season)); @@ -277,32 +282,24 @@ namespace StardewModdingAPI.Utilities /// <param name="year">The year.</param> private bool IsDayZero(int day, string season, int year) { - return day == 0 && season == "spring" && year == 1; + return day == 0 && season?.Trim().ToLower() == "spring" && year == 1; } /// <summary>Get the day of week for a given date.</summary> /// <param name="day">The day of month.</param> private DayOfWeek GetDayOfWeek(int day) { - switch (day % 7) + return (day % 7) switch { - case 0: - return DayOfWeek.Sunday; - case 1: - return DayOfWeek.Monday; - case 2: - return DayOfWeek.Tuesday; - case 3: - return DayOfWeek.Wednesday; - case 4: - return DayOfWeek.Thursday; - case 5: - return DayOfWeek.Friday; - case 6: - return DayOfWeek.Saturday; - default: - return 0; - } + 0 => DayOfWeek.Sunday, + 1 => DayOfWeek.Monday, + 2 => DayOfWeek.Tuesday, + 3 => DayOfWeek.Wednesday, + 4 => DayOfWeek.Thursday, + 5 => DayOfWeek.Friday, + 6 => DayOfWeek.Saturday, + _ => 0 + }; } /// <summary>Get the number of days since the game began (starting at 1 for the first day of spring in Y1).</summary> diff --git a/src/SMAPI/i18n/uk.json b/src/SMAPI/i18n/uk.json index d84aabcf..a4286363 100644 --- a/src/SMAPI/i18n/uk.json +++ b/src/SMAPI/i18n/uk.json @@ -1,6 +1,6 @@ { // short date format for SDate // tokens: {{day}} (like 15), {{season}} (like Spring), {{seasonLowercase}} (like spring), {{year}} (like 2) - "generic.date": "{{season}} День {{day}}", - "generic.date-with-year": "{{season}} День {{day}}, Рік {{year}}" + "generic.date": "{{season}}, {{day}}-й день", + "generic.date-with-year": "{{season}}, {{day}}-й день, {{year}} рік" } |