diff options
author | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
---|---|---|
committer | Jesse Plamondon-Willard <Pathoschild@users.noreply.github.com> | 2019-11-24 13:49:30 -0500 |
commit | a3f21685049cabf2d824c8060dc0b1de47e9449e (patch) | |
tree | ad9add30e9da2a50e0ea0245f1546b7378f0d282 /src | |
parent | 6521df7b131924835eb797251c1e956fae0d6e13 (diff) | |
parent | 277bf082675b98b95bf6184fe3c7a45b969c7ac2 (diff) | |
download | SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.gz SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.tar.bz2 SMAPI-a3f21685049cabf2d824c8060dc0b1de47e9449e.zip |
Merge branch 'develop' into stable
Diffstat (limited to 'src')
306 files changed, 8737 insertions, 6572 deletions
diff --git a/src/SMAPI.Installer/Framework/InstallerPaths.cs b/src/SMAPI.Installer/Framework/InstallerPaths.cs index e5396018..9393e14f 100644 --- a/src/SMAPI.Installer/Framework/InstallerPaths.cs +++ b/src/SMAPI.Installer/Framework/InstallerPaths.cs @@ -59,7 +59,7 @@ namespace StardewModdingAPI.Installer.Framework this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley"); this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI"); this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original"); - this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "StardewModdingAPI.config.json"); + this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json"); } } } diff --git a/src/SMAPI.Installer/InteractiveInstaller.cs b/src/SMAPI.Installer/InteractiveInstaller.cs index 7148b1d9..964300ac 100644 --- a/src/SMAPI.Installer/InteractiveInstaller.cs +++ b/src/SMAPI.Installer/InteractiveInstaller.cs @@ -7,7 +7,6 @@ using System.Threading; using Microsoft.Win32; using StardewModdingApi.Installer.Enums; using StardewModdingAPI.Installer.Framework; -using StardewModdingAPI.Internal; using StardewModdingAPI.Internal.ConsoleWriting; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModScanning; @@ -37,64 +36,7 @@ namespace StardewModdingApi.Installer "SMAPI.ConsoleCommands" }; - /// <summary>The default file paths where Stardew Valley can be installed.</summary> - /// <param name="platform">The target platform.</param> - /// <remarks>Derived from the crossplatform mod config: https://github.com/Pathoschild/Stardew.ModBuildConfig. </remarks> - private IEnumerable<string> GetDefaultInstallPaths(Platform platform) - { - switch (platform) - { - case Platform.Linux: - case Platform.Mac: - { - string home = Environment.GetEnvironmentVariable("HOME"); - // Linux - yield return $"{home}/GOG Games/Stardew Valley/game"; - yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") - ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" - : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; - - // Mac - yield return "/Applications/Stardew Valley.app/Contents/MacOS"; - yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; - } - break; - - case Platform.Windows: - { - // Windows - foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) - { - yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; - yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; - yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; - } - - // Windows registry - IDictionary<string, string> registryKeys = new Dictionary<string, string> - { - [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150"] = "InstallLocation", // Steam - [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows - }; - foreach (var pair in registryKeys) - { - string path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); - if (!string.IsNullOrWhiteSpace(path)) - yield return path; - } - - // via Steam library path - string steampath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); - if (steampath != null) - yield return Path.Combine(steampath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); - } - break; - - default: - throw new InvalidOperationException($"Unknown platform '{platform}'."); - } - } /// <summary>Get the absolute file or folder paths to remove when uninstalling SMAPI.</summary> /// <param name="installDir">The folder for Stardew Valley and SMAPI.</param> @@ -112,6 +54,7 @@ namespace StardewModdingApi.Installer yield return GetInstallPath("StardewModdingAPI.pdb"); // Windows only yield return GetInstallPath("StardewModdingAPI.xml"); yield return GetInstallPath("smapi-internal"); + yield return GetInstallPath("steam_appid.txt"); // obsolete yield return GetInstallPath(Path.Combine("Mods", ".cache")); // 1.3-1.4 @@ -133,11 +76,9 @@ namespace StardewModdingApi.Installer yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb"); // moved in 2.8 yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml"); // moved in 2.8 - yield return GetInstallPath("StardewModdingAPI.xml"); // moved in 2.8 yield return GetInstallPath("System.Numerics.dll"); // moved in 2.8 yield return GetInstallPath("System.Runtime.Caching.dll"); // moved in 2.8 yield return GetInstallPath("System.ValueTuple.dll"); // moved in 2.8 - yield return GetInstallPath("steam_appid.txt"); // moved in 2.8 if (modsDir.Exists) { @@ -159,13 +100,13 @@ namespace StardewModdingApi.Installer public InteractiveInstaller(string bundlePath) { this.BundlePath = bundlePath; - this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.AutoDetect); + this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform()); } /// <summary>Run the install or uninstall script.</summary> /// <param name="args">The command line arguments.</param> /// <remarks> - /// Initialisation flow: + /// Initialization flow: /// 1. Collect information (mainly OS and install path) and validate it. /// 2. Ask the user whether to install or uninstall. /// @@ -187,8 +128,9 @@ namespace StardewModdingApi.Installer ** Step 1: initial setup *********/ /**** - ** Get platform & set window title + ** Get basic info & set window title ****/ + ModToolkit toolkit = new ModToolkit(); Platform platform = EnvironmentUtility.DetectPlatform(); Console.Title = $"SMAPI {this.GetDisplayVersion(this.GetType().Assembly.GetName().Version)} installer on {platform} {EnvironmentUtility.GetFriendlyPlatformName(platform)}"; Console.WriteLine(); @@ -275,8 +217,8 @@ namespace StardewModdingApi.Installer ** show theme selector ****/ // get theme writers - var lightBackgroundWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.LightBackground); - var darkDarkgroundWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.DarkBackground); + var lightBackgroundWriter = new ColorfulConsoleWriter(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.LightBackground)); + var darkBackgroundWriter = new ColorfulConsoleWriter(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.DarkBackground)); // print question this.PrintPlain("Which text looks more readable?"); @@ -284,7 +226,7 @@ namespace StardewModdingApi.Installer Console.Write(" [1] "); lightBackgroundWriter.WriteLine("Dark text on light background", ConsoleLogLevel.Info); Console.Write(" [2] "); - darkDarkgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); + darkBackgroundWriter.WriteLine("Light text on dark background", ConsoleLogLevel.Info); Console.WriteLine(); // handle choice @@ -297,7 +239,7 @@ namespace StardewModdingApi.Installer break; case "2": scheme = MonitorColorScheme.DarkBackground; - this.ConsoleWriter = darkDarkgroundWriter; + this.ConsoleWriter = darkBackgroundWriter; break; default: throw new InvalidOperationException($"Unexpected action key '{choice}'."); @@ -324,7 +266,7 @@ namespace StardewModdingApi.Installer ****/ // get game path this.PrintInfo("Where is your game folder?"); - DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, gamePathArg); + DirectoryInfo installDir = this.InteractivelyGetInstallPath(platform, toolkit, gamePathArg); if (installDir == null) { this.PrintError("Failed finding your game path."); @@ -490,7 +432,6 @@ namespace StardewModdingApi.Installer { this.PrintDebug("Adding bundled mods..."); - ModToolkit toolkit = new ModToolkit(); ModFolder[] targetMods = toolkit.GetModFolders(paths.ModsPath).ToArray(); foreach (ModFolder sourceMod in toolkit.GetModFolders(bundledModsDir.FullName)) { @@ -529,7 +470,7 @@ namespace StardewModdingApi.Installer { string text = File .ReadAllText(paths.ApiConfigPath) - .Replace(@"""ColorScheme"": ""AutoDetect""", $@"""ColorScheme"": ""{scheme}"""); + .Replace(@"""UseScheme"": ""AutoDetect""", $@"""UseScheme"": ""{scheme}"""); File.WriteAllText(paths.ApiConfigPath, text); } @@ -598,32 +539,6 @@ namespace StardewModdingApi.Installer } } - /// <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) - { - RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; - RegistryKey openKey = localMachine.OpenSubKey(key); - if (openKey == null) - return null; - using (openKey) - return (string)openKey.GetValue(name); - } - - /// <summary>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) - { - 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); - } - /// <summary>Print a message without formatting.</summary> /// <param name="text">The text to print.</param> private void PrintPlain(string text) => Console.WriteLine(text); @@ -731,7 +646,7 @@ namespace StardewModdingApi.Installer /// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary> /// <param name="entry">The file or folder to reset.</param> - /// <remarks>This method is mirred from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks> + /// <remarks>This method is mirrored from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks> private void ForceDelete(FileSystemInfo entry) { // ignore if already deleted @@ -789,8 +704,9 @@ namespace StardewModdingApi.Installer /// <summary>Interactively locate the game install path to update.</summary> /// <param name="platform">The current platform.</param> + /// <param name="toolkit">The mod toolkit.</param> /// <param name="specifiedPath">The path specified as a command-line argument (if any), which should override automatic path detection.</param> - private DirectoryInfo InteractivelyGetInstallPath(Platform platform, string specifiedPath) + private DirectoryInfo InteractivelyGetInstallPath(Platform platform, ModToolkit toolkit, string specifiedPath) { // get executable name string executableFilename = EnvironmentUtility.GetExecutableName(platform); @@ -813,18 +729,7 @@ namespace StardewModdingApi.Installer } // get installed paths - DirectoryInfo[] defaultPaths = - ( - from path in this.GetDefaultInstallPaths(platform).Distinct(StringComparer.InvariantCultureIgnoreCase) - let dir = new DirectoryInfo(path) - where dir.Exists && dir.EnumerateFiles(executableFilename).Any() - select dir - ) - .GroupBy(p => p.FullName, StringComparer.InvariantCultureIgnoreCase) // ignore duplicate paths - .Select(p => p.First()) - .ToArray(); - - // choose where to install + DirectoryInfo[] defaultPaths = toolkit.GetGameFolders().ToArray(); if (defaultPaths.Any()) { // only one path @@ -857,7 +762,7 @@ namespace StardewModdingApi.Installer continue; } - // normalise path + // normalize path if (platform == Platform.Windows) path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path if (platform == Platform.Linux || platform == Platform.Mac) diff --git a/src/SMAPI.Installer/Program.cs b/src/SMAPI.Installer/Program.cs index 3c4d8593..b7fa45f5 100644 --- a/src/SMAPI.Installer/Program.cs +++ b/src/SMAPI.Installer/Program.cs @@ -36,7 +36,7 @@ namespace StardewModdingApi.Installer FileInfo zipFile = new FileInfo(Path.Combine(Program.InstallerPath, $"{(platform == PlatformID.Win32NT ? "windows" : "unix")}-install.dat")); if (!zipFile.Exists) { - Console.WriteLine($"Oops! Some of the installer files are missing; try redownloading the installer. (Missing file: {zipFile.FullName})"); + Console.WriteLine($"Oops! Some of the installer files are missing; try re-downloading the installer. (Missing file: {zipFile.FullName})"); Console.ReadLine(); return; } diff --git a/src/SMAPI.Installer/Properties/AssemblyInfo.cs b/src/SMAPI.Installer/Properties/AssemblyInfo.cs deleted file mode 100644 index 9838e5a0..00000000 --- a/src/SMAPI.Installer/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Installer")] -[assembly: AssemblyDescription("The SMAPI installer for players.")] diff --git a/src/SMAPI.Installer/README.txt b/src/SMAPI.Installer/README.txt index 79c90cc0..0da49a46 100644 --- a/src/SMAPI.Installer/README.txt +++ b/src/SMAPI.Installer/README.txt @@ -40,5 +40,5 @@ When installing on Linux or Mac: - Make sure Mono is installed (normally the installer checks for you). While it's not required, many mods won't work correctly without it. (Specifically, mods which load PNG images may crash or freeze the game.) -- To configure the color scheme, edit the `smapi-internal/StardewModdingAPI.config.json` file and - see instructions there for the 'ColorScheme' setting. +- To configure the color scheme, edit the `smapi-internal/config.json` file and see instructions + there for the 'ColorScheme' setting. diff --git a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj b/src/SMAPI.Installer/SMAPI.Installer.csproj index ac64a774..3f01c8fe 100644 --- a/src/SMAPI.Installer/StardewModdingAPI.Installer.csproj +++ b/src/SMAPI.Installer/SMAPI.Installer.csproj @@ -1,10 +1,10 @@ -<Project Sdk="Microsoft.NET.Sdk"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> + <AssemblyName>SMAPI.Installer</AssemblyName> <RootNamespace>StardewModdingAPI.Installer</RootNamespace> - <AssemblyName>StardewModdingAPI.Installer</AssemblyName> + <Description>The SMAPI installer for players.</Description> <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> <OutputType>Exe</OutputType> <PlatformTarget>x86</PlatformTarget> @@ -13,11 +13,7 @@ </PropertyGroup> <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh index a98d527d..f81828f0 100644 --- a/src/SMAPI.Installer/unix-launcher.sh +++ b/src/SMAPI.Installer/unix-launcher.sh @@ -1,14 +1,14 @@ -#!/bin/bash +#!/usr/bin/env bash # MonoKickstart Shell Script # Written by Ethan "flibitijibibo" Lee -# Modified for StardewModdingAPI by Viz and Pathoschild +# Modified for SMAPI by various contributors # Move to script's directory -cd "`dirname "$0"`" +cd "$(dirname "$0")" || exit $? # Get the system architecture -UNAME=`uname` -ARCH=`uname -m` +UNAME=$(uname) +ARCH=$(uname -m) # MonoKickstart picks the right libfolder, so just execute the right binary. if [ "$UNAME" == "Darwin" ]; then @@ -39,18 +39,18 @@ if [ "$UNAME" == "Darwin" ]; then # launch SMAPI cp StardewValley.bin.osx StardewModdingAPI.bin.osx - open -a Terminal ./StardewModdingAPI.bin.osx $@ + open -a Terminal ./StardewModdingAPI.bin.osx "$@" else # choose launcher LAUNCHER="" if [ "$ARCH" == "x86_64" ]; then ln -sf mcs.bin.x86_64 mcs cp StardewValley.bin.x86_64 StardewModdingAPI.bin.x86_64 - LAUNCHER="./StardewModdingAPI.bin.x86_64 $@" + LAUNCHER="./StardewModdingAPI.bin.x86_64 $*" else ln -sf mcs.bin.x86 mcs cp StardewValley.bin.x86 StardewModdingAPI.bin.x86 - LAUNCHER="./StardewModdingAPI.bin.x86 $@" + LAUNCHER="./StardewModdingAPI.bin.x86 $*" fi # get cross-distro version of POSIX command @@ -61,37 +61,57 @@ else COMMAND="type" fi - # open SMAPI in terminal - if $COMMAND xterm 2>/dev/null; then - xterm -e "$LAUNCHER" - elif $COMMAND x-terminal-emulator 2>/dev/null; then - # Terminator converts -e to -x when used through x-terminal-emulator for some reason (per - # `man terminator`), which causes an "unable to find shell" error. If x-terminal-emulator - # is mapped to Terminator, invoke it directly instead. - if [[ "$(readlink -e $(which x-terminal-emulator))" == *"/terminator" ]]; then - terminator -e "sh -c 'TERM=xterm $LAUNCHER'" - else - x-terminal-emulator -e "sh -c 'TERM=xterm $LAUNCHER'" + # select terminal (prefer $TERMINAL for overrides and testing, then xterm for best compatibility, then known supported terminals) + for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite x-terminal-emulator; do + if $COMMAND "$terminal" 2>/dev/null; then + # Find the true shell behind x-terminal-emulator + if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then + export LAUNCHTERM=$terminal + break; + else + export LAUNCHTERM="$(basename "$(readlink -f $(which x-terminal-emulator))")" + # Remember that we're using x-terminal-emulator just in case it points outside the $PATH + export XTE=1 + break; + fi fi - elif $COMMAND xfce4-terminal 2>/dev/null; then - xfce4-terminal -e "sh -c 'TERM=xterm $LAUNCHER'" - elif $COMMAND gnome-terminal 2>/dev/null; then - gnome-terminal -e "sh -c 'TERM=xterm $LAUNCHER'" - elif $COMMAND konsole 2>/dev/null; then - konsole -p Environment=TERM=xterm -e "$LAUNCHER" - elif $COMMAND terminal 2>/dev/null; then - terminal -e "sh -c 'TERM=xterm $LAUNCHER'" - elif $COMMAND termite 2>/dev/null; then - termite -e "sh -c 'TERM=xterm $LAUNCHER'" - else + done + + # if no terminal was found, run in current shell or with no output + if [ -z "$LAUNCHTERM" ]; then sh -c 'TERM=xterm $LAUNCHER' + if [ $? -eq 127 ]; then + $LAUNCHER --no-terminal + fi + exit fi - # some Linux users get error 127 (command not found) from the above block, even though - # `command -v` indicates the command is valid. As a fallback, launch SMAPI without a terminal when - # that happens and pass in an argument indicating SMAPI shouldn't try writing to the terminal - # (which can be slow if there is none). - if [ $? -eq 127 ]; then - $LAUNCHER --no-terminal - fi + # run in selected terminal and account for quirks + case $LAUNCHTERM in + terminator) + # Terminator converts -e to -x when used through x-terminal-emulator for some reason + if $XTE; then + terminator -e "sh -c 'TERM=xterm $LAUNCHER'" + else + terminator -x "sh -c 'TERM=xterm $LAUNCHER'" + fi + ;; + kitty) + # Kitty overrides the TERM varible unless you set it explicitly + kitty -o term=xterm $LAUNCHER + ;; + xterm|xfce4-terminal|gnome-terminal|terminal|termite) + $LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'" + ;; + konsole) + konsole -p Environment=TERM=xterm -e "$LAUNCHER" + ;; + *) + # If we don't know the terminal, just try to run it in the current shell. + sh -c 'TERM=xterm $LAUNCHER' + # if THAT fails, launch with no output + if [ $? -eq 127 ]; then + $LAUNCHER --no-terminal + fi + esac fi diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs new file mode 100644 index 00000000..001840bf --- /dev/null +++ b/src/SMAPI.Internal/ConsoleWriting/ColorSchemeConfig.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; + +namespace StardewModdingAPI.Internal.ConsoleWriting +{ + /// <summary>The console color scheme options.</summary> + internal class ColorSchemeConfig + { + /// <summary>The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</summary> + public MonitorColorScheme UseScheme { get; set; } + + /// <summary>The available console color schemes.</summary> + public IDictionary<MonitorColorScheme, IDictionary<ConsoleLogLevel, ConsoleColor>> Schemes { get; set; } + } +} diff --git a/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs b/src/SMAPI.Internal/ConsoleWriting/ColorfulConsoleWriter.cs index cdc729e2..aefda9b6 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 StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Internal.ConsoleWriting { @@ -21,11 +22,16 @@ namespace StardewModdingAPI.Internal.ConsoleWriting *********/ /// <summary>Construct an instance.</summary> /// <param name="platform">The target platform.</param> - /// <param name="colorScheme">The console color scheme to use.</param> - public ColorfulConsoleWriter(Platform platform, MonitorColorScheme colorScheme) + public ColorfulConsoleWriter(Platform platform) + : this(platform, ColorfulConsoleWriter.GetDefaultColorSchemeConfig(MonitorColorScheme.AutoDetect)) { } + + /// <summary>Construct an instance.</summary> + /// <param name="platform">The target platform.</param> + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> + public ColorfulConsoleWriter(Platform platform, ColorSchemeConfig colorConfig) { this.SupportsColor = this.TestColorSupport(); - this.Colors = this.GetConsoleColorScheme(platform, colorScheme); + this.Colors = this.GetConsoleColorScheme(platform, colorConfig); } /// <summary>Write a message line to the log.</summary> @@ -53,6 +59,40 @@ namespace StardewModdingAPI.Internal.ConsoleWriting Console.WriteLine(message); } + /// <summary>Get the default color scheme config for cases where it's not configurable (e.g. the installer).</summary> + /// <param name="useScheme">The default color scheme ID to use, or <see cref="MonitorColorScheme.AutoDetect"/> to select one automatically.</param> + /// <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>> + { + [MonitorColorScheme.DarkBackground] = new Dictionary<ConsoleLogLevel, ConsoleColor> + { + [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Info] = ConsoleColor.White, + [ConsoleLogLevel.Warn] = ConsoleColor.Yellow, + [ConsoleLogLevel.Error] = ConsoleColor.Red, + [ConsoleLogLevel.Alert] = ConsoleColor.Magenta, + [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen + }, + [MonitorColorScheme.LightBackground] = new Dictionary<ConsoleLogLevel, ConsoleColor> + { + [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, + [ConsoleLogLevel.Info] = ConsoleColor.Black, + [ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow, + [ConsoleLogLevel.Error] = ConsoleColor.Red, + [ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta, + [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen + } + } + }; + } + /********* ** Private methods @@ -73,47 +113,22 @@ namespace StardewModdingAPI.Internal.ConsoleWriting /// <summary>Get the color scheme to use for the current console.</summary> /// <param name="platform">The target platform.</param> - /// <param name="colorScheme">The console color scheme to use.</param> - private IDictionary<ConsoleLogLevel, ConsoleColor> GetConsoleColorScheme(Platform platform, MonitorColorScheme colorScheme) + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> + private IDictionary<ConsoleLogLevel, ConsoleColor> GetConsoleColorScheme(Platform platform, ColorSchemeConfig colorConfig) { - // auto detect color scheme - if (colorScheme == MonitorColorScheme.AutoDetect) + // get color scheme ID + MonitorColorScheme schemeID = colorConfig.UseScheme; + if (schemeID == MonitorColorScheme.AutoDetect) { - colorScheme = platform == Platform.Mac + schemeID = platform == Platform.Mac ? MonitorColorScheme.LightBackground // MacOS doesn't provide console background color info, but it's usually white. : ColorfulConsoleWriter.IsDark(Console.BackgroundColor) ? MonitorColorScheme.DarkBackground : MonitorColorScheme.LightBackground; } // get colors for scheme - switch (colorScheme) - { - case MonitorColorScheme.DarkBackground: - return new Dictionary<ConsoleLogLevel, ConsoleColor> - { - [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Info] = ConsoleColor.White, - [ConsoleLogLevel.Warn] = ConsoleColor.Yellow, - [ConsoleLogLevel.Error] = ConsoleColor.Red, - [ConsoleLogLevel.Alert] = ConsoleColor.Magenta, - [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen - }; - - case MonitorColorScheme.LightBackground: - return new Dictionary<ConsoleLogLevel, ConsoleColor> - { - [ConsoleLogLevel.Trace] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Debug] = ConsoleColor.DarkGray, - [ConsoleLogLevel.Info] = ConsoleColor.Black, - [ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow, - [ConsoleLogLevel.Error] = ConsoleColor.Red, - [ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta, - [ConsoleLogLevel.Success] = ConsoleColor.DarkGreen - }; - - default: - throw new NotSupportedException($"Unknown color scheme '{colorScheme}'."); - } + return colorConfig.Schemes.TryGetValue(schemeID, out IDictionary<ConsoleLogLevel, ConsoleColor> scheme) + ? scheme + : throw new NotSupportedException($"Unknown color scheme '{schemeID}'."); } /// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary> @@ -125,7 +140,7 @@ namespace StardewModdingAPI.Internal.ConsoleWriting case ConsoleColor.Black: case ConsoleColor.Blue: case ConsoleColor.DarkBlue: - case ConsoleColor.DarkMagenta: // Powershell + case ConsoleColor.DarkMagenta: // PowerShell case ConsoleColor.DarkRed: case ConsoleColor.Red: return true; diff --git a/src/SMAPI.Internal/ConsoleWriting/LogLevel.cs b/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs index 54564111..54564111 100644 --- a/src/SMAPI.Internal/ConsoleWriting/LogLevel.cs +++ b/src/SMAPI.Internal/ConsoleWriting/ConsoleLogLevel.cs diff --git a/src/SMAPI.Internal/SMAPI.Internal.projitems b/src/SMAPI.Internal/SMAPI.Internal.projitems index 54b12003..7fcebc94 100644 --- a/src/SMAPI.Internal/SMAPI.Internal.projitems +++ b/src/SMAPI.Internal/SMAPI.Internal.projitems @@ -10,9 +10,8 @@ </PropertyGroup> <ItemGroup> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorfulConsoleWriter.cs" /> - <Compile Include="$(MSBuildThisFileDirectory)EnvironmentUtility.cs" /> - <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\LogLevel.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorSchemeConfig.cs" /> + <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ConsoleLogLevel.cs" /> <Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\MonitorColorScheme.cs" /> - <Compile Include="$(MSBuildThisFileDirectory)Platform.cs" /> </ItemGroup> </Project>
\ No newline at end of file diff --git a/src/SMAPI.Internal/StardewModdingAPI.Internal.shproj b/src/SMAPI.Internal/SMAPI.Internal.shproj index a098828a..a098828a 100644 --- a/src/SMAPI.Internal/StardewModdingAPI.Internal.shproj +++ b/src/SMAPI.Internal/SMAPI.Internal.shproj diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs index 1684229a..140c6f59 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs @@ -2,7 +2,7 @@ namespace Netcode { /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetFieldBase</c> for unit testing.</summary> - /// <typeparam name="T">The type of the synchronised value.</typeparam> + /// <typeparam name="T">The type of the synchronized value.</typeparam> /// <typeparam name="TSelf">The type of the current instance.</typeparam> public class NetFieldBase<T, TSelf> where TSelf : NetFieldBase<T, TSelf> { diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs index 85a77d15..89bd1be5 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs @@ -96,7 +96,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests DiagnosticResult expected = new DiagnosticResult { 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/buildmsg/avoid-implicit-net-field-cast for details.", + 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.", Severity = DiagnosticSeverity.Warning, Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) } }; @@ -138,7 +138,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests DiagnosticResult expected = new DiagnosticResult { Id = "AvoidNetField", - Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.", + Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/package/avoid-net-field for details.", Severity = DiagnosticSeverity.Warning, Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) } }; diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs index fa9235a3..12641e1a 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs @@ -67,7 +67,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests DiagnosticResult expected = new DiagnosticResult { Id = "AvoidObsoleteField", - Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.", + Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/package/avoid-obsolete-field for details.", Severity = DiagnosticSeverity.Warning, Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) } }; diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj index 45953eec..7e3ce7d4 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj @@ -6,14 +6,16 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" /> - <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.9.0" /> - <PackageReference Include="NUnit" Version="3.11.0" /> - <PackageReference Include="NUnit3TestAdapter" Version="3.11.0" /> + <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" /> + <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" /> + <PackageReference Include="NUnit" Version="3.12.0" /> + <PackageReference Include="NUnit3TestAdapter" Version="3.15.1" /> </ItemGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj" /> + <ProjectReference Include="..\SMAPI.ModBuildConfig.Analyzer\SMAPI.ModBuildConfig.Analyzer.csproj" /> </ItemGroup> + <Import Project="..\..\build\common.targets" /> + </Project> diff --git a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs index f2608348..a9b981bd 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs @@ -135,22 +135,22 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new DiagnosticDescriptor( 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/buildmsg/avoid-implicit-net-field-cast for details.", + 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.", category: "SMAPI.CommonErrors", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/buildmsg/avoid-implicit-net-field-cast" + helpLinkUri: "https://smapi.io/package/avoid-implicit-net-field-cast" ); /// <summary>The diagnostic info for an avoidable net field access.</summary> private readonly DiagnosticDescriptor AvoidNetFieldRule = new DiagnosticDescriptor( id: "AvoidNetField", title: "Avoid Netcode types when possible", - messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.", + messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/package/avoid-net-field for details.", category: "SMAPI.CommonErrors", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/buildmsg/avoid-net-field" + helpLinkUri: "https://smapi.io/package/avoid-net-field" ); @@ -199,7 +199,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer /********* ** Private methods *********/ - /// <summary>Analyse a member access syntax node and add a diagnostic message if applicable.</summary> + /// <summary>Analyze a member access syntax node and add a diagnostic message if applicable.</summary> /// <param name="context">The analysis context.</param> /// <returns>Returns whether any warnings were added.</returns> private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context) @@ -231,7 +231,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer }); } - /// <summary>Analyse an explicit cast or 'x as y' node and add a diagnostic message if applicable.</summary> + /// <summary>Analyze an explicit cast or 'x as y' node and add a diagnostic message if applicable.</summary> /// <param name="context">The analysis context.</param> /// <returns>Returns whether any warnings were added.</returns> private void AnalyzeCast(SyntaxNodeAnalysisContext context) @@ -248,7 +248,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer }); } - /// <summary>Analyse a binary comparison syntax node and add a diagnostic message if applicable.</summary> + /// <summary>Analyze a binary comparison syntax node and add a diagnostic message if applicable.</summary> /// <param name="context">The analysis context.</param> /// <returns>Returns whether any warnings were added.</returns> private void AnalyzeBinaryComparison(SyntaxNodeAnalysisContext context) @@ -288,7 +288,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer } /// <summary>Handle exceptions raised while analyzing a node.</summary> - /// <param name="node">The node being analysed.</param> + /// <param name="node">The node being analyzed.</param> /// <param name="action">The callback to invoke.</param> private void HandleErrors(SyntaxNode node, Action action) { diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs index f1a3ef75..d071f0c1 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs +++ b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs @@ -27,11 +27,11 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer ["AvoidObsoleteField"] = new DiagnosticDescriptor( id: "AvoidObsoleteField", title: "Reference to obsolete field", - messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.", + messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/package/avoid-obsolete-field for details.", category: "SMAPI.CommonErrors", defaultSeverity: DiagnosticSeverity.Warning, isEnabledByDefault: true, - helpLinkUri: "https://smapi.io/buildmsg/avoid-obsolete-field" + helpLinkUri: "https://smapi.io/package/avoid-obsolete-field" ) }; @@ -67,7 +67,7 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer /********* ** Private methods *********/ - /// <summary>Analyse a syntax node and add a diagnostic message if it references an obsolete field.</summary> + /// <summary>Analyze a syntax node and add a diagnostic message if it references an obsolete field.</summary> /// <param name="context">The analysis context.</param> private void AnalyzeObsoleteFields(SyntaxNodeAnalysisContext context) { diff --git a/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs deleted file mode 100644 index 1cc41000..00000000 --- a/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.ModBuildConfig.Analyzer")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj index 1d8d7227..3659e25a 100644 --- a/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj +++ b/src/SMAPI.ModBuildConfig.Analyzer/SMAPI.ModBuildConfig.Analyzer.csproj @@ -1,19 +1,18 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFramework>netstandard1.3</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <AssemblyName>SMAPI.ModBuildConfig.Analyzer</AssemblyName> + <RootNamespace>StardewModdingAPI.ModBuildConfig.Analyzer</RootNamespace> + <Version>3.0.0</Version> + <TargetFramework>netstandard2.0</TargetFramework> + <LangVersion>latest</LangVersion> <IncludeBuildOutput>false</IncludeBuildOutput> <OutputPath>bin</OutputPath> <LangVersion>latest</LangVersion> </PropertyGroup> <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" PrivateAssets="all" /> + <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" PrivateAssets="all" /> <PackageReference Update="NETStandard.Library" PrivateAssets="all" /> </ItemGroup> diff --git a/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1 b/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1 deleted file mode 100644 index ff051759..00000000 --- a/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1 +++ /dev/null @@ -1,58 +0,0 @@ -param($installPath, $toolsPath, $package, $project) - -if($project.Object.SupportsPackageDependencyResolution) -{ - if($project.Object.SupportsPackageDependencyResolution()) - { - # Do not install analyzers via install.ps1, instead let the project system handle it. - return - } -} - -$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve - -foreach($analyzersPath in $analyzersPaths) -{ - if (Test-Path $analyzersPath) - { - # Install the language agnostic analyzers. - foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll) - { - if($project.Object.AnalyzerReferences) - { - $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName) - } - } - } -} - -# $project.Type gives the language name like (C# or VB.NET) -$languageFolder = "" -if($project.Type -eq "C#") -{ - $languageFolder = "cs" -} -if($project.Type -eq "VB.NET") -{ - $languageFolder = "vb" -} -if($languageFolder -eq "") -{ - return -} - -foreach($analyzersPath in $analyzersPaths) -{ - # Install language specific analyzers. - $languageAnalyzersPath = join-path $analyzersPath $languageFolder - if (Test-Path $languageAnalyzersPath) - { - foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll) - { - if($project.Object.AnalyzerReferences) - { - $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName) - } - } - } -} diff --git a/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1 b/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1 deleted file mode 100644 index 4bed3337..00000000 --- a/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1 +++ /dev/null @@ -1,65 +0,0 @@ -param($installPath, $toolsPath, $package, $project) - -if($project.Object.SupportsPackageDependencyResolution) -{ - if($project.Object.SupportsPackageDependencyResolution()) - { - # Do not uninstall analyzers via uninstall.ps1, instead let the project system handle it. - return - } -} - -$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve - -foreach($analyzersPath in $analyzersPaths) -{ - # Uninstall the language agnostic analyzers. - if (Test-Path $analyzersPath) - { - foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll) - { - if($project.Object.AnalyzerReferences) - { - $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName) - } - } - } -} - -# $project.Type gives the language name like (C# or VB.NET) -$languageFolder = "" -if($project.Type -eq "C#") -{ - $languageFolder = "cs" -} -if($project.Type -eq "VB.NET") -{ - $languageFolder = "vb" -} -if($languageFolder -eq "") -{ - return -} - -foreach($analyzersPath in $analyzersPaths) -{ - # Uninstall language specific analyzers. - $languageAnalyzersPath = join-path $analyzersPath $languageFolder - if (Test-Path $languageAnalyzersPath) - { - foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll) - { - if($project.Object.AnalyzerReferences) - { - try - { - $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName) - } - catch - { - - } - } - } - } -} diff --git a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs index e03683d0..a852f133 100644 --- a/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs +++ b/src/SMAPI.ModBuildConfig/Framework/ModFileManager.cs @@ -3,8 +3,9 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Text.RegularExpressions; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.ModBuildConfig.Framework { @@ -40,47 +41,14 @@ namespace StardewModdingAPI.ModBuildConfig.Framework if (!Directory.Exists(targetDir)) throw new UserErrorException("Could not create mod package because no build output was found."); - // project manifest - bool hasProjectManifest = false; - { - FileInfo manifest = new FileInfo(Path.Combine(projectDir, "manifest.json")); - if (manifest.Exists) - { - this.Files[this.ManifestFileName] = manifest; - hasProjectManifest = true; - } - } - - // project i18n files - bool hasProjectTranslations = false; - DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n")); - if (translationsFolder.Exists) - { - foreach (FileInfo file in translationsFolder.EnumerateFiles()) - this.Files[Path.Combine("i18n", file.Name)] = file; - hasProjectTranslations = true; - } - - // build output - DirectoryInfo buildFolder = new DirectoryInfo(targetDir); - foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + // collect files + foreach (Tuple<string, FileInfo> entry in this.GetPossibleFiles(projectDir, targetDir)) { - // get relative paths - string relativePath = file.FullName.Replace(buildFolder.FullName, ""); - string relativeDirPath = file.Directory.FullName.Replace(buildFolder.FullName, ""); + string relativePath = entry.Item1; + FileInfo file = entry.Item2; - // prefer project manifest/i18n files - if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) - continue; - if (hasProjectTranslations && this.EqualsInvariant(relativeDirPath, "i18n")) - continue; - - // handle ignored files - if (this.ShouldIgnore(file, relativePath, ignoreFilePatterns)) - continue; - - // add file - this.Files[relativePath] = file; + if (!this.ShouldIgnore(file, relativePath, ignoreFilePatterns)) + this.Files[relativePath] = file; } // check for required files @@ -117,6 +85,67 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /********* ** Private methods *********/ + /// <summary>Get all files to include in the mod folder, not accounting for ignore patterns.</summary> + /// <param name="projectDir">The folder containing the project files.</param> + /// <param name="targetDir">The folder containing the build output.</param> + /// <returns>Returns tuples containing the relative path within the mod folder, and the file to copy to it.</returns> + private IEnumerable<Tuple<string, FileInfo>> GetPossibleFiles(string projectDir, string targetDir) + { + // project manifest + bool hasProjectManifest = false; + { + FileInfo manifest = new FileInfo(Path.Combine(projectDir, this.ManifestFileName)); + if (manifest.Exists) + { + yield return Tuple.Create(this.ManifestFileName, manifest); + hasProjectManifest = true; + } + } + + // project i18n files + bool hasProjectTranslations = false; + DirectoryInfo translationsFolder = new DirectoryInfo(Path.Combine(projectDir, "i18n")); + if (translationsFolder.Exists) + { + foreach (FileInfo file in translationsFolder.EnumerateFiles()) + yield return Tuple.Create(Path.Combine("i18n", file.Name), file); + hasProjectTranslations = true; + } + + // project assets folder + bool hasAssetsFolder = false; + DirectoryInfo assetsFolder = new DirectoryInfo(Path.Combine(projectDir, "assets")); + if (assetsFolder.Exists) + { + foreach (FileInfo file in assetsFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + string relativePath = PathUtilities.GetRelativePath(projectDir, file.FullName); + yield return Tuple.Create(relativePath, file); + } + hasAssetsFolder = true; + } + + // build output + DirectoryInfo buildFolder = new DirectoryInfo(targetDir); + foreach (FileInfo file in buildFolder.EnumerateFiles("*", SearchOption.AllDirectories)) + { + // get path info + string relativePath = PathUtilities.GetRelativePath(buildFolder.FullName, file.FullName); + string[] segments = PathUtilities.GetSegments(relativePath); + + // prefer project manifest/i18n/assets files + if (hasProjectManifest && this.EqualsInvariant(relativePath, this.ManifestFileName)) + continue; + if (hasProjectTranslations && this.EqualsInvariant(segments[0], "i18n")) + continue; + if (hasAssetsFolder && this.EqualsInvariant(segments[0], "assets")) + continue; + + // add file + yield return Tuple.Create(relativePath, file); + } + } + /// <summary>Get whether a build output file should be ignored.</summary> /// <param name="file">The file to check.</param> /// <param name="relativePath">The file's relative path in the package.</param> @@ -129,6 +158,7 @@ namespace StardewModdingAPI.ModBuildConfig.Framework // Json.NET (bundled into SMAPI) || this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll") + || this.EqualsInvariant(file.Name, "Newtonsoft.Json.pdb") || this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml") // code analysis files @@ -148,6 +178,8 @@ namespace StardewModdingAPI.ModBuildConfig.Framework /// <param name="other">The string to compare with.</param> private bool EqualsInvariant(string str, string other) { + if (str == null) + return other == null; return str.Equals(other, StringComparison.InvariantCultureIgnoreCase); } } diff --git a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs deleted file mode 100644 index e051bfbd..00000000 --- a/src/SMAPI.ModBuildConfig/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,6 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.ModBuildConfig")] -[assembly: AssemblyDescription("")] -[assembly: AssemblyVersion("2.2.0")] -[assembly: AssemblyFileVersion("2.2.0")] diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj index 44f0a3e7..ccbd9a85 100644 --- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj +++ b/src/SMAPI.ModBuildConfig/SMAPI.ModBuildConfig.csproj @@ -1,23 +1,22 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> + <AssemblyName>SMAPI.ModBuildConfig</AssemblyName> <RootNamespace>StardewModdingAPI.ModBuildConfig</RootNamespace> - <AssemblyName>StardewModdingAPI.ModBuildConfig</AssemblyName> + <Version>3.0.0</Version> <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> <PlatformTarget>x86</PlatformTarget> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> - <None Include="..\..\docs\mod-build-config.md"> - <Link>mod-build-config.md</Link> - </None> + <None Include="..\..\build\find-game-folder.targets" Link="build\find-game-folder.targets" /> + <None Include="..\..\docs\technical\mod-package.md" Link="mod-build-config.md" /> </ItemGroup> <ItemGroup> @@ -28,7 +27,20 @@ <Reference Include="System.Web.Extensions" /> </ItemGroup> + <ItemGroup> + <None Include="..\..\docs\technical\mod-package.md"> + <Link>mod-package.md</Link> + </None> + </ItemGroup> + + <ItemGroup> + <None Update="assets\nuget-icon.png"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <Import Project="..\..\build\common.targets" /> + <Import Project="..\..\build\prepare-nuget-package.targets" /> </Project> diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index e6c3fa57..5ca9f032 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -1,175 +1,113 @@ <Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <!--********************************************* - ** Import build tasks - **********************************************--> - <UsingTask TaskName="DeployModTask" AssemblyFile="StardewModdingAPI.ModBuildConfig.dll" /> + + <Import Project="find-game-folder.targets" /> + <UsingTask TaskName="DeployModTask" AssemblyFile="SMAPI.ModBuildConfig.dll" /> + <!--********************************************* - ** Find the basic mod metadata + ** Set build options **********************************************--> - <!-- import developer's custom settings (if any) --> - <Import Condition="$(OS) != 'Windows_NT' AND Exists('$(HOME)\stardewvalley.targets')" Project="$(HOME)\stardewvalley.targets" /> - <Import Condition="$(OS) == 'Windows_NT' AND Exists('$(USERPROFILE)\stardewvalley.targets')" Project="$(USERPROFILE)\stardewvalley.targets" /> - - <!-- set setting defaults --> <PropertyGroup> - <!-- map legacy settings --> - <ModFolderName Condition="'$(ModFolderName)' == '' AND '$(DeployModFolderName)' != ''">$(DeployModFolderName)</ModFolderName> - <ModZipPath Condition="'$(ModZipPath)' == '' AND '$(DeployModZipTo)' != ''">$(DeployModZipTo)</ModZipPath> + <!-- include PDB file by default to enable line numbers in stack traces --> + <DebugType>pdbonly</DebugType> + <DebugSymbols>true</DebugSymbols> - <!-- set default settings --> + <!-- recognise XNA Framework DLLs in the GAC (only affects mods using new csproj format) --> + <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> + + <!-- set default package options --> <ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName> <ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath> - <EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy> - <EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip> - <CopyModReferencesToBuildOutput Condition="'$(CopyModReferencesToBuildOutput)' == ''">False</CopyModReferencesToBuildOutput> + <EnableModDeploy Condition="'$(EnableModDeploy)' == ''">true</EnableModDeploy> + <EnableModZip Condition="'$(EnableModZip)' == ''">true</EnableModZip> + <EnableHarmony Condition="'$(EnableModZip)' == ''">false</EnableHarmony> + <EnableGameDebugging Condition="$(EnableGameDebugging) == ''">true</EnableGameDebugging> + <CopyModReferencesToBuildOutput Condition="'$(CopyModReferencesToBuildOutput)' == '' OR ('$(CopyModReferencesToBuildOutput)' != 'true' AND '$(CopyModReferencesToBuildOutput)' != 'false')">false</CopyModReferencesToBuildOutput> </PropertyGroup> - <!-- find platform + game path --> - <Choose> - <When Condition="$(OS) == 'Unix' OR $(OS) == 'OSX'"> - <PropertyGroup> - <!-- Linux --> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.steam/steam/steamapps/common/Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath> - - <!-- Mac (may be 'Unix' or 'OSX') --> - <GamePath Condition="!Exists('$(GamePath)')">/Applications/Stardew Valley.app/Contents/MacOS</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath> - </PropertyGroup> - </When> - <When Condition="$(OS) == 'Windows_NT'"> - <PropertyGroup> - <!-- default paths --> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GalaxyClient\Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\GOG Galaxy\Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files\Steam\steamapps\common\Stardew Valley</GamePath> - - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath> - - <!-- registry paths --> - <GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath> - <GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath> - - <!-- derive from Steam library path --> - <_SteamLibraryPath>$([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Valve\Steam', 'SteamPath', null, RegistryView.Registry32))</_SteamLibraryPath> - <GamePath Condition="!Exists('$(GamePath)') AND '$(_SteamLibraryPath)' != ''">$(_SteamLibraryPath)\steamapps\common\Stardew Valley</GamePath> - </PropertyGroup> - </When> - </Choose> + <PropertyGroup Condition="$(OS) == 'Windows_NT' AND $(EnableGameDebugging) == 'true'"> + <!-- enable game debugging --> + <StartAction>Program</StartAction> + <StartProgram>$(GamePath)\StardewModdingAPI.exe</StartProgram> + <StartWorkingDirectory>$(GamePath)</StartWorkingDirectory> + </PropertyGroup> <!--********************************************* - ** Inject the assembly references and debugging configuration + ** Add assembly references **********************************************--> - <Choose> - <When Condition="$(OS) == 'Windows_NT'"> - <!-- references --> - <ItemGroup> - <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="Netcode"> - <HintPath>$(GamePath)\Netcode.dll</HintPath> - <Private>False</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="Stardew Valley"> - <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="StardewModdingAPI"> - <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces"> - <HintPath>$(GamePath)\smapi-internal\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> - <HintPath Condition="!Exists('$(GamePath)\smapi-internal')">$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>false</Private> - <SpecificVersion>False</SpecificVersion> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - </ItemGroup> - - <!-- launch game for debugging --> - <PropertyGroup> - <StartAction>Program</StartAction> - <StartProgram>$(GamePath)\StardewModdingAPI.exe</StartProgram> - <StartWorkingDirectory>$(GamePath)</StartWorkingDirectory> - </PropertyGroup> - </When> - <Otherwise> - <!-- references --> - <ItemGroup> - <Reference Include="MonoGame.Framework"> - <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> - <Private>false</Private> - <SpecificVersion>False</SpecificVersion> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="StardewValley"> - <HintPath>$(GamePath)\StardewValley.exe</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="StardewModdingAPI"> - <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces"> - <HintPath>$(GamePath)\smapi-internal\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> - <HintPath Condition="!Exists('$(GamePath)\smapi-internal')">$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - <Reference Include="xTile"> - <HintPath>$(GamePath)\xTile.dll</HintPath> - <Private>false</Private> - <Private Condition="$(CopyModReferencesToBuildOutput)">true</Private> - </Reference> - </ItemGroup> - </Otherwise> - </Choose> + <!-- common --> + <ItemGroup> + <Reference Include="$(GameExecutableName)"> + <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="StardewValley.GameData"> + <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="StardewModdingAPI"> + <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="SMAPI.Toolkit.CoreInterfaces"> + <HintPath>$(GamePath)\smapi-internal\SMAPI.Toolkit.CoreInterfaces.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="xTile"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="0Harmony" Condition="'$(EnableHarmony)' == 'true'"> + <HintPath>$(GamePath)\smapi-internal\0Harmony.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + </ItemGroup> + + <!-- Windows --> + <ItemGroup Condition="$(OS) == 'Windows_NT'"> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + <Reference Include="Netcode"> + <HintPath>$(GamePath)\Netcode.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + </ItemGroup> + + <!-- Linux/Mac --> + <ItemGroup Condition="$(OS) != 'Windows_NT'"> + <Reference Include="MonoGame.Framework"> + <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> + <Private>$(CopyModReferencesToBuildOutput)</Private> + </Reference> + </ItemGroup> <!--********************************************* - ** Deploy mod files & create release zip after build + ** Show friendly error for invalid OS or game path **********************************************--> - <!-- if game path or OS is invalid, show one user-friendly error instead of a slew of reference errors --> <Target Name="BeforeBuild"> <Error Condition="'$(OS)' != 'OSX' AND '$(OS)' != 'Unix' AND '$(OS)' != 'Windows_NT'" Text="The mod build package doesn't recognise OS type '$(OS)'." /> - <Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see https://smapi.io/buildmsg/game-path." /> - <Error Condition="'$(OS)' == 'Windows_NT' AND !Exists('$(GamePath)\Stardew Valley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the Stardew Valley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." /> - <Error Condition="'$(OS)' != 'Windows_NT' AND !Exists('$(GamePath)\StardewValley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the StardewValley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." /> + <Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see https://smapi.io/package/custom-game-path." /> + <Error Condition="!Exists('$(GamePath)\$(GameExecutableName).exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the $(GameExecutableName) file. If this folder is invalid, delete it and the package will autodetect another game install path." /> <Error Condition="!Exists('$(GamePath)\StardewModdingAPI.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain SMAPI. You need to install SMAPI before building the mod." /> </Target> - <!-- deploy mod files & create release zip --> + + <!--********************************************* + ** Deploy mod files & create release zip + **********************************************--> <Target Name="AfterBuild"> <DeployModTask ModFolderName="$(ModFolderName)" diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index 21693828..846f438d 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,20 +2,35 @@ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <metadata> <id>Pathoschild.Stardew.ModBuildConfig</id> - <version>2.2</version> + <version>3.0.0</version> <title>Build package for SMAPI mods</title> <authors>Pathoschild</authors> <owners>Pathoschild</owners> <requireLicenseAcceptance>false</requireLicenseAcceptance> - <licenseUrl>https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt</licenseUrl> - <projectUrl>https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme</projectUrl> + <license type="expression">MIT</license> + <repository type="git" url="https://github.com/Pathoschild/SMAPI" /> + <projectUrl>https://smapi.io/package/readme</projectUrl> + <icon>images\icon.png</icon> <iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl> - <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For Stardew Valley 1.3 or later.</description> + <description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For SMAPI 3.0 or later.</description> <releaseNotes> - 2.2: - - Added support for SMAPI 2.8+ (still compatible with earlier versions). - - Added default game paths for 32-bit Windows. - - Fixed valid manifests marked invalid in some cases. + 3.0.0: + - Updated for SMAPI 3.0 and Stardew Valley 1.4. + - Added automatic support for 'assets' folders. + - Added $(GameExecutableName) MSBuild variable. + - Added support for projects using the simplified .csproj format. + - Added option to disable game debugging config. + - Added .pdb files to builds by default (to enable line numbers in error stack traces). + - Added optional Harmony reference. + - Fixed Newtonsoft.Json.pdb included in release zips when Json.NET is referenced directly. + - Fixed <IgnoreModFilePatterns> not working for i18n files. + - Dropped support for older versions of SMAPI and Visual Studio. + - Migrated package icon to NuGet's new format. </releaseNotes> </metadata> + <files> + <file src="analyzers\**" target="analyzers" /> + <file src="build\**" target="build" /> + <file src="images\**" target="images" /> + </files> </package> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs index 263e126c..6cb2b624 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/AddCommand.cs @@ -15,7 +15,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <summary>Provides methods for searching and constructing items.</summary> private readonly ItemRepository Items = new ItemRepository(); - /// <summary>The type names recognised by this command.</summary> + /// <summary>The type names recognized by this command.</summary> private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray(); diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs index 5b52e9a2..4232ce16 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/ListItemsCommand.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player /// <param name="searchWords">The search string to find.</param> private IEnumerable<SearchableItem> GetItems(string[] searchWords) { - // normalise search term + // normalize search term searchWords = searchWords?.Where(word => !string.IsNullOrWhiteSpace(word)).ToArray(); if (searchWords?.Any() == false) searchWords = null; diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs index ad11cc66..1706bbc1 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/Player/SetMoneyCommand.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using StardewValley; namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player @@ -65,7 +65,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player public override void Update(IMonitor monitor) { if (this.InfiniteMoney) - Game1.player.money = 999999; + Game1.player.Money = 999999; } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs index a6075013..9eae6741 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/Commands/World/SetTimeCommand.cs @@ -60,7 +60,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World { for (int i = 0; i > intervals; i--) { - Game1.timeOfDay = FromTimeSpan(ToTimeSpan(Game1.timeOfDay).Subtract(TimeSpan.FromMinutes(20))); // offset 20 mins so game updates to next interval + Game1.timeOfDay = FromTimeSpan(ToTimeSpan(Game1.timeOfDay).Subtract(TimeSpan.FromMinutes(20))); // offset 20 minutes so game updates to next interval Game1.performTenMinuteClockUpdate(); } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs index 7ee662d0..5d269c89 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemData/ItemType.cs @@ -6,28 +6,31 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData /// <summary>A big craftable object in <see cref="StardewValley.Game1.bigCraftablesInformation"/></summary> BigCraftable, - /// <summary>A <see cref="Boots"/> item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Boots"/> item.</summary> Boots, - /// <summary>A <see cref="Wallpaper"/> flooring item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Clothing"/> item.</summary> + Clothing, + + /// <summary>A <see cref="StardewValley.Objects.Wallpaper"/> flooring item.</summary> Flooring, - /// <summary>A <see cref="Furniture"/> item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Furniture"/> item.</summary> Furniture, - /// <summary>A <see cref="Hat"/> item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Hat"/> item.</summary> Hat, /// <summary>Any object in <see cref="StardewValley.Game1.objectInformation"/> (except rings).</summary> Object, - /// <summary>A <see cref="Ring"/> item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Ring"/> item.</summary> Ring, - /// <summary>A <see cref="Tool"/> tool.</summary> + /// <summary>A <see cref="StardewValley.Tool"/> tool.</summary> Tool, - /// <summary>A <see cref="Wallpaper"/> wall item.</summary> + /// <summary>A <see cref="StardewValley.Objects.Wallpaper"/> wall item.</summary> Wallpaper, /// <summary>A <see cref="StardewValley.Tools.MeleeWeapon"/> or <see cref="StardewValley.Tools.Slingshot"/> item.</summary> diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs index fc631826..0648aa2b 100644 --- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs +++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs @@ -1,7 +1,11 @@ +using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.Xna.Framework; using StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData; using StardewValley; +using StardewValley.Menus; using StardewValley.Objects; using StardewValley.Tools; using SObject = StardewValley.Object; @@ -22,172 +26,234 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework ** Public methods *********/ /// <summary>Get all spawnable items.</summary> + [SuppressMessage("ReSharper", "AccessToModifiedClosure", Justification = "TryCreate invokes the lambda immediately.")] public IEnumerable<SearchableItem> GetAll() { - // get tools - yield return new SearchableItem(ItemType.Tool, ToolFactory.axe, ToolFactory.getToolFromDescription(ToolFactory.axe, 0)); - yield return new SearchableItem(ItemType.Tool, ToolFactory.hoe, ToolFactory.getToolFromDescription(ToolFactory.hoe, 0)); - yield return new SearchableItem(ItemType.Tool, ToolFactory.pickAxe, ToolFactory.getToolFromDescription(ToolFactory.pickAxe, 0)); - yield return new SearchableItem(ItemType.Tool, ToolFactory.wateringCan, ToolFactory.getToolFromDescription(ToolFactory.wateringCan, 0)); - yield return new SearchableItem(ItemType.Tool, ToolFactory.fishingRod, ToolFactory.getToolFromDescription(ToolFactory.fishingRod, 0)); - yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset, new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones - yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 1, new Shears()); - yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 2, new Pan()); - yield return new SearchableItem(ItemType.Tool, this.CustomIDOffset + 3, new Wand()); - - // wallpapers - for (int id = 0; id < 112; id++) - yield return new SearchableItem(ItemType.Wallpaper, id, new Wallpaper(id) { Category = SObject.furnitureCategory }); - - // flooring - for (int id = 0; id < 40; id++) - yield return new SearchableItem(ItemType.Flooring, id, new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory }); - - // equipment - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys) - yield return new SearchableItem(ItemType.Boots, id, new Boots(id)); - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys) - yield return new SearchableItem(ItemType.Hat, id, new Hat(id)); - foreach (int id in Game1.objectInformation.Keys) + IEnumerable<SearchableItem> GetAllRaw() { - if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) - yield return new SearchableItem(ItemType.Ring, id, new Ring(id)); - } + // get tools + for (int quality = Tool.stone; quality <= Tool.iridium; quality++) + { + yield return this.TryCreate(ItemType.Tool, ToolFactory.axe, () => ToolFactory.getToolFromDescription(ToolFactory.axe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.hoe, () => ToolFactory.getToolFromDescription(ToolFactory.hoe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.pickAxe, () => ToolFactory.getToolFromDescription(ToolFactory.pickAxe, quality)); + yield return this.TryCreate(ItemType.Tool, ToolFactory.wateringCan, () => ToolFactory.getToolFromDescription(ToolFactory.wateringCan, quality)); + if (quality != Tool.iridium) + yield return this.TryCreate(ItemType.Tool, ToolFactory.fishingRod, () => ToolFactory.getToolFromDescription(ToolFactory.fishingRod, quality)); + } + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset, () => new MilkPail()); // these don't have any sort of ID, so we'll just assign some arbitrary ones + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 1, () => new Shears()); + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, () => new Pan()); + yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, () => new Wand()); - // weapons - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys) - { - Item weapon = (id >= 32 && id <= 34) - ? (Item)new Slingshot(id) - : new MeleeWeapon(id); - yield return new SearchableItem(ItemType.Weapon, id, weapon); - } + // wallpapers + for (int id = 0; id < 112; id++) + yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory }); - // furniture - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Furniture").Keys) - { - if (id == 1466 || id == 1468) - yield return new SearchableItem(ItemType.Furniture, id, new TV(id, Vector2.Zero)); - else - yield return new SearchableItem(ItemType.Furniture, id, new Furniture(id, Vector2.Zero)); - } + // flooring + for (int id = 0; id < 40; id++) + yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory }); - // craftables - foreach (int id in Game1.bigCraftablesInformation.Keys) - yield return new SearchableItem(ItemType.BigCraftable, id, new SObject(Vector2.Zero, id)); + // equipment + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Boots").Keys) + yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id)); + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys) + yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id)); + foreach (int id in Game1.objectInformation.Keys) + { + if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) + yield return this.TryCreate(ItemType.Ring, id, () => new Ring(id)); + } - // secret notes - foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys) - { - SObject note = new SObject(79, 1); - note.name = $"{note.name} #{id}"; - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, note); - } + // weapons + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys) + { + yield return this.TryCreate(ItemType.Weapon, id, () => (id >= 32 && id <= 34) + ? (Item)new Slingshot(id) + : new MeleeWeapon(id) + ); + } - // objects - foreach (int id in Game1.objectInformation.Keys) - { - if (id == 79) - continue; // secret note handled above - if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) - continue; // handled separated + // furniture + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\Furniture").Keys) + { + if (id == 1466 || id == 1468) + yield return this.TryCreate(ItemType.Furniture, id, () => new TV(id, Vector2.Zero)); + else + yield return this.TryCreate(ItemType.Furniture, id, () => new Furniture(id, Vector2.Zero)); + } - SObject item = new SObject(id, 1); - yield return new SearchableItem(ItemType.Object, id, item); + // craftables + foreach (int id in Game1.bigCraftablesInformation.Keys) + yield return this.TryCreate(ItemType.BigCraftable, id, () => new SObject(Vector2.Zero, id)); - // fruit products - if (item.Category == SObject.FruitsCategory) + // secret notes + foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys) { - // wine - SObject wine = new SObject(348, 1) - { - Name = $"{item.Name} Wine", - Price = item.Price * 3 - }; - wine.preserve.Value = SObject.PreserveType.Wine; - wine.preservedParentSheetIndex.Value = item.ParentSheetIndex; - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, wine); - - // jelly - SObject jelly = new SObject(344, 1) + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + id, () => { - Name = $"{item.Name} Jelly", - Price = 50 + item.Price * 2 - }; - jelly.preserve.Value = SObject.PreserveType.Jelly; - jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex; - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, jelly); + SObject note = new SObject(79, 1); + note.name = $"{note.name} #{id}"; + return note; + }); } - // vegetable products - else if (item.Category == SObject.VegetableCategory) + // objects + foreach (int id in Game1.objectInformation.Keys) { - // juice - SObject juice = new SObject(350, 1) - { - Name = $"{item.Name} Juice", - Price = (int)(item.Price * 2.25d) - }; - juice.preserve.Value = SObject.PreserveType.Juice; - juice.preservedParentSheetIndex.Value = item.ParentSheetIndex; - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, juice); - - // pickled - SObject pickled = new SObject(342, 1) + if (id == 79) + continue; // secret note handled above + if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange) + continue; // handled separated + + // spawn main item + SObject item; { - Name = $"Pickled {item.Name}", - Price = 50 + item.Price * 2 - }; - pickled.preserve.Value = SObject.PreserveType.Pickle; - pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex; - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, pickled); - } + SearchableItem main = this.TryCreate(ItemType.Object, id, () => id == 812 + ? new ColoredObject(id, 1, Color.White) + : new SObject(id, 1) + ); + yield return main; + item = main?.Item as SObject; + } + if (item == null) + continue; - // flower honey - else if (item.Category == SObject.flowersCategory) - { - // get honey type - SObject.HoneyType? type = null; - switch (item.ParentSheetIndex) + // fruit products + if (item.Category == SObject.FruitsCategory) { - case 376: - type = SObject.HoneyType.Poppy; - break; - case 591: - type = SObject.HoneyType.Tulip; - break; - case 593: - type = SObject.HoneyType.SummerSpangle; - break; - case 595: - type = SObject.HoneyType.FairyRose; - break; - case 597: - type = SObject.HoneyType.BlueJazz; - break; - case 421: // sunflower standing in for all other flowers - type = SObject.HoneyType.Wild; - break; + // wine + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () => + { + SObject wine = new SObject(348, 1) + { + Name = $"{item.Name} Wine", + Price = item.Price * 3 + }; + wine.preserve.Value = SObject.PreserveType.Wine; + wine.preservedParentSheetIndex.Value = item.ParentSheetIndex; + return wine; + }); + + // jelly + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () => + { + SObject jelly = new SObject(344, 1) + { + Name = $"{item.Name} Jelly", + Price = 50 + item.Price * 2 + }; + jelly.preserve.Value = SObject.PreserveType.Jelly; + jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex; + return jelly; + }); } - // yield honey - if (type != null) + // vegetable products + else if (item.Category == SObject.VegetableCategory) { - SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false) + // juice + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () => + { + SObject juice = new SObject(350, 1) + { + Name = $"{item.Name} Juice", + Price = (int)(item.Price * 2.25d) + }; + juice.preserve.Value = SObject.PreserveType.Juice; + juice.preservedParentSheetIndex.Value = item.ParentSheetIndex; + return juice; + }); + + // pickled + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => { - Name = "Wild Honey" - }; - honey.honeyType.Value = type; + SObject pickled = new SObject(342, 1) + { + Name = $"Pickled {item.Name}", + Price = 50 + item.Price * 2 + }; + pickled.preserve.Value = SObject.PreserveType.Pickle; + pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex; + return pickled; + }); + } - if (type != SObject.HoneyType.Wild) + // flower honey + else if (item.Category == SObject.flowersCategory) + { + yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => { - honey.Name = $"{item.Name} Honey"; + 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; + }); + } + + // roe and aged roe (derived from FishPond.GetFishProduce) + else if (id == 812) + { + foreach (var pair in Game1.objectInformation) + { + // get input + SObject input = new SObject(pair.Key, 1); + if (input.Category != SObject.FishCategory) + continue; + Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange; + + // yield roe + SObject roe = new ColoredObject(812, 1, color) + { + name = $"{input.Name} Roe", + preserve = { Value = SObject.PreserveType.Roe }, + preservedParentSheetIndex = { Value = input.ParentSheetIndex } + }; + roe.Price += input.Price / 2; + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, roe); + + // aged roe + if (pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item + { + ColoredObject agedRoe = 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 + }; + yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, agedRoe); + } } - yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, honey); } } } + + return GetAllRaw().Where(p => p != null); + } + + + /********* + ** Private methods + *********/ + /// <summary>Create a searchable item if valid.</summary> + /// <param name="type">The item type.</param> + /// <param name="id">The unique ID (if different from the item's parent sheet index).</param> + /// <param name="createItem">Create an item instance.</param> + private SearchableItem TryCreate(ItemType type, int id, Func<Item> createItem) + { + try + { + return new SearchableItem(type, id, createItem()); + } + catch + { + return null; // if some item data is invalid, just don't include it + } } } } diff --git a/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs b/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs deleted file mode 100644 index 86653141..00000000 --- a/src/SMAPI.Mods.ConsoleCommands/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Mods.ConsoleCommands")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj new file mode 100644 index 00000000..ce35bf73 --- /dev/null +++ b/src/SMAPI.Mods.ConsoleCommands/SMAPI.Mods.ConsoleCommands.csproj @@ -0,0 +1,73 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <AssemblyName>ConsoleCommands</AssemblyName> + <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> + <TargetFramework>net45</TargetFramework> + <LangVersion>latest</LangVersion> + <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\ConsoleCommands</OutputPath> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <PlatformTarget>x86</PlatformTarget> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\SMAPI\SMAPI.csproj"> + <Private>False</Private> + </ProjectReference> + </ItemGroup> + + <ItemGroup> + <Reference Include="$(GameExecutableName)"> + <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="StardewValley.GameData"> + <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + + <Choose> + <!-- Windows --> + <When Condition="$(OS) == 'Windows_NT'"> + <ItemGroup> + <Reference Include="Netcode"> + <HintPath>$(GamePath)\Netcode.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + </ItemGroup> + </When> + + <!-- Linux/Mac --> + <Otherwise> + <ItemGroup> + <Reference Include="MonoGame.Framework"> + <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + </Otherwise> + </Choose> + + <ItemGroup> + <None Update="manifest.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <Import Project="..\..\build\common.targets" /> + +</Project> diff --git a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj deleted file mode 100644 index b535e2fd..00000000 --- a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj +++ /dev/null @@ -1,35 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace> - <AssemblyName>ConsoleCommands</AssemblyName> - <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <LangVersion>latest</LangVersion> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\ConsoleCommands</OutputPath> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <PlatformTarget>x86</PlatformTarget> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj"> - <Private>False</Private> - </ProjectReference> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> - <Link>Properties\GlobalAssemblyInfo.cs</Link> - </Compile> - </ItemGroup> - - <ItemGroup> - <None Update="manifest.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - </ItemGroup> - - <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> - <Import Project="..\..\build\common.targets" /> - -</Project> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 74295410..802de3a6 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,9 +1,9 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "2.11.3", + "Version": "3.0.0", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll", - "MinimumApiVersion": "2.11.3" + "MinimumApiVersion": "3.0.0" } diff --git a/src/SMAPI.Mods.SaveBackup/ModEntry.cs b/src/SMAPI.Mods.SaveBackup/ModEntry.cs index 30dbfbe6..3b47759b 100644 --- a/src/SMAPI.Mods.SaveBackup/ModEntry.cs +++ b/src/SMAPI.Mods.SaveBackup/ModEntry.cs @@ -4,6 +4,7 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Reflection; +using System.Threading.Tasks; using StardewValley; namespace StardewModdingAPI.Mods.SaveBackup @@ -40,9 +41,10 @@ namespace StardewModdingAPI.Mods.SaveBackup DirectoryInfo backupFolder = new DirectoryInfo(this.BackupFolder); backupFolder.Create(); - // back up saves - this.CreateBackup(backupFolder); - this.PruneBackups(backupFolder, this.BackupsToKeep); + // back up & prune saves + Task + .Run(() => this.CreateBackup(backupFolder)) + .ContinueWith(backupTask => this.PruneBackups(backupFolder, this.BackupsToKeep)); } catch (Exception ex) { @@ -66,48 +68,23 @@ namespace StardewModdingAPI.Mods.SaveBackup if (targetFile.Exists || fallbackDir.Exists) return; - // create zip - // due to limitations with the bundled Mono on Mac, we can't reference System.IO.Compression. - this.Monitor.Log($"Adding {targetFile.Name}...", LogLevel.Trace); - switch (Constants.TargetPlatform) + // back up saves + this.Monitor.Log($"Backing up saves to {targetFile.FullName}...", LogLevel.Trace); + if (!this.TryCompress(Constants.SavesPath, targetFile, out Exception compressError)) { - case GamePlatform.Linux: - case GamePlatform.Windows: - { - 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."); - 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."); - MethodInfo createMethod = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method."); - createMethod.Invoke(null, new object[] { Constants.SavesPath, targetFile.FullName, CompressionLevel.Fastest, false }); - } - catch (Exception ex) when (ex is TypeLoadException || ex.InnerException is TypeLoadException) - { - // create uncompressed backup if compression fails - this.Monitor.Log("Couldn't zip the save backup, creating uncompressed backup instead.", LogLevel.Debug); - this.Monitor.Log(ex.ToString(), LogLevel.Trace); - this.RecursiveCopy(new DirectoryInfo(Constants.SavesPath), fallbackDir, copyRoot: false); - } - } - break; - - case GamePlatform.Mac: - { - DirectoryInfo saveFolder = new DirectoryInfo(Constants.SavesPath); - ProcessStartInfo startInfo = new ProcessStartInfo - { - FileName = "zip", - Arguments = $"-rq \"{targetFile.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"", - WorkingDirectory = $"{Constants.SavesPath}/../", - CreateNoWindow = true - }; - new Process { StartInfo = startInfo }.Start(); - } - break; + // log error (expected on Android due to missing compression DLLs) + if (Constants.TargetPlatform == GamePlatform.Android) + this.Monitor.VerboseLog($"Compression isn't supported on Android:\n{compressError}"); + else + { + this.Monitor.Log("Couldn't zip the save backup, creating uncompressed backup instead.", LogLevel.Debug); + this.Monitor.Log(compressError.ToString(), LogLevel.Trace); + } + + // fallback to uncompressed + this.RecursiveCopy(new DirectoryInfo(Constants.SavesPath), fallbackDir, copyRoot: false); } + this.Monitor.Log("Backup done!", LogLevel.Trace); } catch (Exception ex) { @@ -124,20 +101,23 @@ namespace StardewModdingAPI.Mods.SaveBackup try { var oldBackups = backupFolder - .GetFiles() + .GetFileSystemInfos() .OrderByDescending(p => p.CreationTimeUtc) .Skip(backupsToKeep); - foreach (FileInfo file in oldBackups) + foreach (FileSystemInfo entry in oldBackups) { try { - this.Monitor.Log($"Deleting {file.Name}...", LogLevel.Trace); - file.Delete(); + this.Monitor.Log($"Deleting {entry.Name}...", LogLevel.Trace); + if (entry is DirectoryInfo folder) + folder.Delete(recursive: true); + else + entry.Delete(); } catch (Exception ex) { - this.Monitor.Log($"Error deleting old save backup '{file.Name}': {ex}", LogLevel.Error); + this.Monitor.Log($"Error deleting old save backup '{entry.Name}': {ex}", LogLevel.Error); } } } @@ -148,6 +128,72 @@ namespace StardewModdingAPI.Mods.SaveBackup } } + /// <summary>Create a zip using the best available method.</summary> + /// <param name="sourcePath">The file or directory path to zip.</param> + /// <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) + { + try + { + if (Constants.TargetPlatform == GamePlatform.Mac) + this.CompressUsingMacProcess(sourcePath, destination); // due to limitations with the bundled Mono on Mac, we can't reference System.IO.Compression + else + this.CompressUsingNetFramework(sourcePath, destination); + + error = null; + return true; + } + catch (Exception ex) + { + error = ex; + return false; + } + } + + /// <summary>Create a zip using the .NET compression library.</summary> + /// <param name="sourcePath">The file or directory path to zip.</param> + /// <param name="destination">The destination file to create.</param> + /// <exception cref="NotSupportedException">The compression libraries aren't available on this system.</exception> + private void CompressUsingNetFramework(string sourcePath, FileInfo destination) + { + // get compress method + MethodInfo createFromDirectory; + 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."); + 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."); + } + catch (Exception ex) + { + throw new NotSupportedException("Couldn't load the .NET compression libraries on this system.", ex); + } + + // compress file + createFromDirectory.Invoke(null, new object[] { sourcePath, destination.FullName, CompressionLevel.Fastest, false }); + } + + /// <summary>Create a zip using a process command on MacOS.</summary> + /// <param name="sourcePath">The file or directory path to zip.</param> + /// <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 + { + FileName = "zip", + Arguments = $"-rq \"{destination.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"", + WorkingDirectory = $"{saveFolder.FullName}/../", + CreateNoWindow = true + }; + new Process { StartInfo = startInfo }.Start(); + } + /// <summary>Recursively copy a directory or file.</summary> /// <param name="source">The file or folder to copy.</param> /// <param name="targetFolder">The folder to copy into.</param> diff --git a/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs b/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs deleted file mode 100644 index fc6b26fa..00000000 --- a/src/SMAPI.Mods.SaveBackup/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("StardewModdingAPI.Mods.SaveBackup")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj index 460f3c93..2d031408 100644 --- a/src/SMAPI.Mods.SaveBackup/StardewModdingAPI.Mods.SaveBackup.csproj +++ b/src/SMAPI.Mods.SaveBackup/SMAPI.Mods.SaveBackup.csproj @@ -1,10 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> <AssemblyName>SaveBackup</AssemblyName> + <RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace> <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\SaveBackup</OutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> @@ -12,15 +11,16 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj"> + <ProjectReference Include="..\SMAPI\SMAPI.csproj"> <Private>False</Private> </ProjectReference> </ItemGroup> <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> - <Link>Properties\GlobalAssemblyInfo.cs</Link> - </Compile> + <Reference Include="$(GameExecutableName)"> + <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> + <Private>False</Private> + </Reference> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json index e147bd39..eddd3443 100644 --- a/src/SMAPI.Mods.SaveBackup/manifest.json +++ b/src/SMAPI.Mods.SaveBackup/manifest.json @@ -1,9 +1,9 @@ { "Name": "Save Backup", "Author": "SMAPI", - "Version": "2.11.3", + "Version": "3.0.0", "Description": "Automatically backs up all your saves once per day into its folder.", "UniqueID": "SMAPI.SaveBackup", "EntryDll": "SaveBackup.dll", - "MinimumApiVersion": "2.11.3" + "MinimumApiVersion": "3.0.0" } diff --git a/src/SMAPI.Tests/Core/ModResolverTests.cs b/src/SMAPI.Tests/Core/ModResolverTests.cs index 4a1f04c6..a9c88c60 100644 --- a/src/SMAPI.Tests/Core/ModResolverTests.cs +++ b/src/SMAPI.Tests/Core/ModResolverTests.cs @@ -5,13 +5,15 @@ using System.Linq; using Moq; using Newtonsoft.Json; using NUnit.Framework; +using StardewModdingAPI; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; +using SemanticVersion = StardewModdingAPI.SemanticVersion; -namespace StardewModdingAPI.Tests.Core +namespace SMAPI.Tests.Core { /// <summary>Unit tests for <see cref="ModResolver"/>.</summary> [TestFixture] @@ -27,7 +29,7 @@ namespace StardewModdingAPI.Tests.Core public void ReadBasicManifest_NoMods_ReturnsEmptyList() { // arrange - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string rootFolder = this.GetTempFolderPath(); Directory.CreateDirectory(rootFolder); // act @@ -41,7 +43,7 @@ namespace StardewModdingAPI.Tests.Core public void ReadBasicManifest_EmptyModFolder_ReturnsFailedManifest() { // arrange - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string rootFolder = this.GetTempFolderPath(); string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); Directory.CreateDirectory(modFolder); @@ -55,7 +57,7 @@ namespace StardewModdingAPI.Tests.Core Assert.IsNotNull(mod.Error, "The mod metadata did not have an error message set."); } - [Test(Description = "Assert that the resolver correctly reads manifest data from a randomised file.")] + [Test(Description = "Assert that the resolver correctly reads manifest data from a randomized file.")] public void ReadBasicManifest_CanReadFile() { // create manifest data @@ -78,7 +80,7 @@ namespace StardewModdingAPI.Tests.Core }; // write to filesystem - string rootFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string rootFolder = this.GetTempFolderPath(); string modFolder = Path.Combine(rootFolder, Guid.NewGuid().ToString("N")); string filename = Path.Combine(modFolder, "manifest.json"); Directory.CreateDirectory(modFolder); @@ -209,7 +211,7 @@ namespace StardewModdingAPI.Tests.Core IManifest manifest = this.GetManifest(); // create DLL - string modFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); + string modFolder = Path.Combine(this.GetTempFolderPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(modFolder); File.WriteAllText(Path.Combine(modFolder, manifest.EntryDll), ""); @@ -462,7 +464,13 @@ namespace StardewModdingAPI.Tests.Core /********* ** Private methods *********/ - /// <summary>Get a randomised basic manifest.</summary> + /// <summary>Get a generated folder path in the temp folder. This folder isn't created automatically.</summary> + private string GetTempFolderPath() + { + return Path.Combine(Path.GetTempPath(), "smapi-unit-tests", Guid.NewGuid().ToString("N")); + } + + /// <summary>Get a randomized basic manifest.</summary> /// <param name="id">The <see cref="IManifest.UniqueID"/> value, or <c>null</c> for a generated value.</param> /// <param name="name">The <see cref="IManifest.Name"/> value, or <c>null</c> for a generated value.</param> /// <param name="version">The <see cref="IManifest.Version"/> value, or <c>null</c> for a generated value.</param> @@ -486,14 +494,14 @@ namespace StardewModdingAPI.Tests.Core }; } - /// <summary>Get a randomised basic manifest.</summary> + /// <summary>Get a randomized basic manifest.</summary> /// <param name="uniqueID">The mod's name and unique ID.</param> private Mock<IModMetadata> GetMetadata(string uniqueID) { return this.GetMetadata(this.GetManifest(uniqueID, "1.0")); } - /// <summary>Get a randomised basic manifest.</summary> + /// <summary>Get a randomized basic manifest.</summary> /// <param name="uniqueID">The mod's name and unique ID.</param> /// <param name="dependencies">The dependencies this mod requires.</param> /// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param> @@ -503,7 +511,7 @@ namespace StardewModdingAPI.Tests.Core return this.GetMetadata(manifest, allowStatusChange); } - /// <summary>Get a randomised basic manifest.</summary> + /// <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) diff --git a/src/SMAPI.Tests/Core/TranslationTests.cs b/src/SMAPI.Tests/Core/TranslationTests.cs index 63404a41..457f9fad 100644 --- a/src/SMAPI.Tests/Core/TranslationTests.cs +++ b/src/SMAPI.Tests/Core/TranslationTests.cs @@ -2,10 +2,11 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using StardewModdingAPI; using StardewModdingAPI.Framework.ModHelpers; using StardewValley; -namespace StardewModdingAPI.Tests.Core +namespace SMAPI.Tests.Core { /// <summary>Unit tests for <see cref="TranslationHelper"/> and <see cref="Translation"/>.</summary> [TestFixture] @@ -31,7 +32,7 @@ namespace StardewModdingAPI.Tests.Core var data = new Dictionary<string, IDictionary<string, string>>(); // act - ITranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + ITranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); Translation translation = helper.Get("key"); Translation[] translationList = helper.GetTranslations()?.ToArray(); @@ -54,7 +55,7 @@ namespace StardewModdingAPI.Tests.Core // act var actual = new Dictionary<string, Translation[]>(); - TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); @@ -78,7 +79,7 @@ namespace StardewModdingAPI.Tests.Core // act var actual = new Dictionary<string, Translation[]>(); - TranslationHelper helper = new TranslationHelper("ModID", "ModName", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); + TranslationHelper helper = new TranslationHelper("ModID", "en", LocalizedContentManager.LanguageCode.en).SetTranslations(data); foreach (string locale in expected.Keys) { this.AssertSetLocale(helper, locale, LocalizedContentManager.LanguageCode.en); @@ -108,14 +109,14 @@ namespace StardewModdingAPI.Tests.Core [TestCase(" boop ", ExpectedResult = true)] public bool Translation_HasValue(string text) { - return new Translation("ModName", "pt-BR", "key", text).HasValue(); + 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) { // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); + Translation translation = new Translation("pt-BR", "key", text); // assert if (translation.HasValue()) @@ -128,7 +129,7 @@ namespace StardewModdingAPI.Tests.Core public void Translation_ImplicitStringConversion([ValueSource(nameof(TranslationTests.Samples))] string text) { // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); + Translation translation = new Translation("pt-BR", "key", text); // assert if (translation.HasValue()) @@ -141,7 +142,7 @@ namespace StardewModdingAPI.Tests.Core public void Translation_UsePlaceholder([Values(true, false)] bool value, [ValueSource(nameof(TranslationTests.Samples))] string text) { // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).UsePlaceholder(value); + Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value); // assert if (translation.HasValue()) @@ -152,24 +153,11 @@ namespace StardewModdingAPI.Tests.Core Assert.AreEqual(this.GetPlaceholderText("key"), translation.ToString(), "The translation returned an unexpected value given a null or empty input with the placeholder enabled."); } - [Test(Description = "Assert that the translation's Assert method throws the expected exception.")] - public void Translation_Assert([ValueSource(nameof(TranslationTests.Samples))] string text) - { - // act - Translation translation = new Translation("ModName", "pt-BR", "key", text); - - // assert - if (translation.HasValue()) - Assert.That(() => translation.Assert(), Throws.Nothing, "The assert unexpected threw an exception for a valid input."); - else - Assert.That(() => translation.Assert(), Throws.Exception.TypeOf<KeyNotFoundException>(), "The assert didn't throw an exception for invalid input."); - } - [Test(Description = "Assert that the translation returns the expected text after setting the default.")] public void Translation_Default([ValueSource(nameof(TranslationTests.Samples))] string text, [ValueSource(nameof(TranslationTests.Samples))] string @default) { // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Default(@default); + Translation translation = new Translation("pt-BR", "key", text).Default(@default); // assert if (!string.IsNullOrEmpty(text)) @@ -194,7 +182,7 @@ namespace StardewModdingAPI.Tests.Core string expected = $"{start} tokens are properly replaced (including {middle} {middle}) {end}"; // act - Translation translation = new Translation("ModName", "pt-BR", "key", input); + Translation translation = new Translation("pt-BR", "key", input); switch (structure) { case "anonymous object": @@ -235,7 +223,7 @@ namespace StardewModdingAPI.Tests.Core string value = Guid.NewGuid().ToString("N"); // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); + Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); // assert Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); @@ -245,13 +233,13 @@ namespace StardewModdingAPI.Tests.Core [TestCase("{{value}}", "value")] [TestCase("{{VaLuE}}", "vAlUe")] [TestCase("{{VaLuE }}", " vAlUe")] - public void Translation_Tokens_KeysAreNormalised(string text, string key) + public void Translation_Tokens_KeysAreNormalized(string text, string key) { // arrange string value = Guid.NewGuid().ToString("N"); // act - Translation translation = new Translation("ModName", "pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); + Translation translation = new Translation("pt-BR", "key", text).Tokens(new Dictionary<string, object> { [key] = value }); // assert Assert.AreEqual(value, translation.ToString(), "The translation returned an unexpected value given a valid base text."); @@ -302,19 +290,19 @@ namespace StardewModdingAPI.Tests.Core { ["default"] = new[] { - new Translation(string.Empty, "default", "key A", "default A"), - new Translation(string.Empty, "default", "key C", "default C") + new Translation("default", "key A", "default A"), + new Translation("default", "key C", "default C") }, ["en"] = new[] { - new Translation(string.Empty, "en", "key A", "en A"), - new Translation(string.Empty, "en", "key B", "en B"), - new Translation(string.Empty, "en", "key C", "default C") + new Translation("en", "key A", "en A"), + new Translation("en", "key B", "en B"), + new Translation("en", "key C", "default C") }, ["zzz"] = new[] { - new Translation(string.Empty, "zzz", "key A", "zzz A"), - new Translation(string.Empty, "zzz", "key C", "default C") + new Translation("zzz", "key A", "zzz A"), + new Translation("zzz", "key C", "default C") } }; expected["en-us"] = expected["en"].ToArray(); diff --git a/src/SMAPI.Tests/Properties/AssemblyInfo.cs b/src/SMAPI.Tests/Properties/AssemblyInfo.cs deleted file mode 100644 index 8b21e8fe..00000000 --- a/src/SMAPI.Tests/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Tests")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.Tests/SMAPI.Tests.csproj b/src/SMAPI.Tests/SMAPI.Tests.csproj new file mode 100644 index 00000000..639c22a4 --- /dev/null +++ b/src/SMAPI.Tests/SMAPI.Tests.csproj @@ -0,0 +1,37 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <AssemblyName>SMAPI.Tests</AssemblyName> + <RootNamespace>SMAPI.Tests</RootNamespace> + <TargetFramework>net45</TargetFramework> + <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <LangVersion>latest</LangVersion> + <PlatformTarget>x86</PlatformTarget> + </PropertyGroup> + + <ItemGroup> + <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="Moq" Version="4.13.1" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + <PackageReference Include="NUnit" Version="3.12.0" /> + </ItemGroup> + + <ItemGroup> + <Reference Include="$(GameExecutableName)"> + <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> + <Private>True</Private> + </Reference> + </ItemGroup> + + <ItemGroup> + <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> + </ItemGroup> + + <Import Project="..\..\build\common.targets" /> + +</Project> diff --git a/src/SMAPI.Tests/Sample.cs b/src/SMAPI.Tests/Sample.cs index 6cd27707..f4f0d88e 100644 --- a/src/SMAPI.Tests/Sample.cs +++ b/src/SMAPI.Tests/Sample.cs @@ -1,6 +1,6 @@ using System; -namespace StardewModdingAPI.Tests +namespace SMAPI.Tests { /// <summary>Provides sample values for unit testing.</summary> internal static class Sample diff --git a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj deleted file mode 100644 index 1cb2d1e6..00000000 --- a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj +++ /dev/null @@ -1,40 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <RootNamespace>StardewModdingAPI.Tests</RootNamespace> - <AssemblyName>StardewModdingAPI.Tests</AssemblyName> - <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <LangVersion>latest</LangVersion> - <PlatformTarget>x86</PlatformTarget> - </PropertyGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj" /> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> - <ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="Castle.Core" Version="4.3.1" /> - <PackageReference Include="Moq" Version="4.10.0" /> - <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> - <PackageReference Include="NUnit" Version="3.11.0" /> - <PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.5.2" /> - <PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.1" /> - <PackageReference Include="System.ValueTuple" Version="4.5.0" /> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs"> - <Link>Properties\GlobalAssemblyInfo.cs</Link> - </Compile> - </ItemGroup> - - <ItemGroup> - <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" /> - </ItemGroup> - - <Import Project="..\..\build\common.targets" /> - -</Project> diff --git a/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs b/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs index 229b9a14..55785bfa 100644 --- a/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs +++ b/src/SMAPI.Tests/Toolkit/PathUtilitiesTests.cs @@ -1,7 +1,7 @@ using NUnit.Framework; using StardewModdingAPI.Toolkit.Utilities; -namespace StardewModdingAPI.Tests.Toolkit +namespace SMAPI.Tests.Toolkit { /// <summary>Unit tests for <see cref="PathUtilities"/>.</summary> [TestFixture] @@ -25,7 +25,7 @@ namespace StardewModdingAPI.Tests.Toolkit return string.Join("|", PathUtilities.GetSegments(path)); } - [Test(Description = "Assert that NormalisePathSeparators returns the expected values.")] + [Test(Description = "Assert that NormalizePathSeparators returns the expected values.")] #if SMAPI_FOR_WINDOWS [TestCase("", ExpectedResult = "")] [TestCase("/", ExpectedResult = "")] @@ -47,9 +47,9 @@ namespace StardewModdingAPI.Tests.Toolkit [TestCase("C:/boop", ExpectedResult = "C:/boop")] [TestCase(@"C:\usr\bin//.././boop.exe", ExpectedResult = "C:/usr/bin/.././boop.exe")] #endif - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return PathUtilities.NormalisePathSeparators(path); + return PathUtilities.NormalizePathSeparators(path); } [Test(Description = "Assert that GetRelativePath returns the expected values.")] diff --git a/src/SMAPI.Tests/Utilities/SDateTests.cs b/src/SMAPI.Tests/Utilities/SDateTests.cs index 1f31168e..d25a101a 100644 --- a/src/SMAPI.Tests/Utilities/SDateTests.cs +++ b/src/SMAPI.Tests/Utilities/SDateTests.cs @@ -6,7 +6,7 @@ using System.Text.RegularExpressions; using NUnit.Framework; using StardewModdingAPI.Utilities; -namespace StardewModdingAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities { /// <summary>Unit tests for <see cref="SDate"/>.</summary> [TestFixture] @@ -159,7 +159,7 @@ namespace StardewModdingAPI.Tests.Utilities [TestCase("15 summer Y1", -28, ExpectedResult = "15 spring Y1")] // negative season transition [TestCase("15 summer Y2", -28 * 4, ExpectedResult = "15 summer Y1")] // negative year transition [TestCase("01 spring Y3", -(28 * 7 + 17), ExpectedResult = "12 spring Y1")] // negative year transition - [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y3")] // test for zero-index errors + [TestCase("06 fall Y2", 50, ExpectedResult = "28 winter Y2")] // test for zero-index errors [TestCase("06 fall Y2", 51, ExpectedResult = "01 spring Y3")] // test for zero-index errors public string AddDays(string dateStr, int addDays) { diff --git a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs index 2e7719eb..c91ec27f 100644 --- a/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs +++ b/src/SMAPI.Tests/Utilities/SemanticVersionTests.cs @@ -2,9 +2,10 @@ using System; using System.Diagnostics.CodeAnalysis; using Newtonsoft.Json; using NUnit.Framework; +using StardewModdingAPI; using StardewModdingAPI.Framework; -namespace StardewModdingAPI.Tests.Utilities +namespace SMAPI.Tests.Utilities { /// <summary>Unit tests for <see cref="SemanticVersion"/>.</summary> [TestFixture] @@ -17,10 +18,10 @@ namespace StardewModdingAPI.Tests.Utilities ** Constructor ****/ [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")] - [TestCase("1.0", ExpectedResult = "1.0")] - [TestCase("1.0.0", ExpectedResult = "1.0")] + [TestCase("1.0", ExpectedResult = "1.0.0")] + [TestCase("1.0.0", ExpectedResult = "1.0.0")] [TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")] - [TestCase("1.2-some-tag.4", ExpectedResult = "1.2-some-tag.4")] + [TestCase("1.2-some-tag.4", ExpectedResult = "1.2.0-some-tag.4")] [TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")] [TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")] [TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")] @@ -30,7 +31,7 @@ namespace StardewModdingAPI.Tests.Utilities } [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")] - [TestCase(1, 0, 0, null, ExpectedResult = "1.0")] + [TestCase(1, 0, 0, null, ExpectedResult = "1.0.0")] [TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")] [TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")] [TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")] @@ -65,7 +66,7 @@ namespace StardewModdingAPI.Tests.Utilities } [Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")] - [TestCase(1, 0, 0, ExpectedResult = "1.0")] + [TestCase(1, 0, 0, ExpectedResult = "1.0.0")] [TestCase(1, 2, 3, ExpectedResult = "1.2.3")] [TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")] public string Constructor_FromAssemblyVersion(int major, int minor, int patch) @@ -243,19 +244,19 @@ namespace StardewModdingAPI.Tests.Utilities } /**** - ** Serialisable + ** Serializable ****/ [Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")] - [TestCase("1.0")] - public void Serialisable(string versionStr) + [TestCase("1.0.0")] + public void Serializable(string versionStr) { // act string json = JsonConvert.SerializeObject(new SemanticVersion(versionStr)); SemanticVersion after = JsonConvert.DeserializeObject<SemanticVersion>(json); // assert - Assert.IsNotNull(after, "The semantic version after deserialisation is unexpectedly null."); - Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialisation doesn't match the input version."); + Assert.IsNotNull(after, "The semantic version after deserialization is unexpectedly null."); + Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version."); } /**** diff --git a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs index 0a6e5758..4ec87d7c 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs +++ b/src/SMAPI.Toolkit.CoreInterfaces/ISemanticVersion.cs @@ -17,12 +17,6 @@ namespace StardewModdingAPI /// <summary>The patch version for backwards-compatible bug fixes.</summary> int PatchVersion { get; } -#if !SMAPI_3_0_STRICT - /// <summary>An optional build tag.</summary> - [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] - string Build { get; } -#endif - /// <summary>An optional prerelease tag.</summary> string PrereleaseTag { get; } @@ -30,7 +24,7 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>Whether this is a pre-release version.</summary> + /// <summary>Whether this is a prerelease version.</summary> bool IsPrerelease(); /// <summary>Get whether this version is older than the specified version.</summary> diff --git a/src/SMAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs b/src/SMAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs deleted file mode 100644 index a29ba6cf..00000000 --- a/src/SMAPI.Toolkit.CoreInterfaces/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Toolkit.CoreInterfaces")] -[assembly: AssemblyDescription("Provides toolkit interfaces which are available to SMAPI mods.")] diff --git a/src/SMAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj index cbbb7fc9..1b9c04ff 100644 --- a/src/SMAPI.Toolkit.CoreInterfaces/StardewModdingAPI.Toolkit.CoreInterfaces.csproj +++ b/src/SMAPI.Toolkit.CoreInterfaces/SMAPI.Toolkit.CoreInterfaces.csproj @@ -1,18 +1,16 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> - <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> + <AssemblyName>SMAPI.Toolkit.CoreInterfaces</AssemblyName> <RootNamespace>StardewModdingAPI</RootNamespace> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> + <Description>Provides toolkit interfaces which are available to SMAPI mods.</Description> + <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces</OutputPath> - <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces\$(TargetFramework)\StardewModdingAPI.Toolkit.CoreInterfaces.xml</DocumentationFile> + <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile> <LangVersion>latest</LangVersion> + <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> </PropertyGroup> - <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - <Import Project="..\..\build\common.targets" /> </Project> diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModEntryModel.cs index 8a9c0a25..f1bcfccc 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> @@ -9,23 +11,31 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The mod's unique ID (if known).</summary> public string ID { get; set; } + /// <summary>The update version recommended by the web API based on its version update and mapping rules.</summary> + public ModEntryVersionModel SuggestedUpdate { get; set; } + + /// <summary>Optional extended data which isn't needed for update checks.</summary> + public ModExtendedMetadataModel Metadata { get; set; } + /// <summary>The main version.</summary> + [Obsolete] public ModEntryVersionModel Main { get; set; } /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary> + [Obsolete] public ModEntryVersionModel Optional { get; set; } /// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary> + [Obsolete] public ModEntryVersionModel Unofficial { get; set; } /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> + [Obsolete] public ModEntryVersionModel UnofficialForBeta { get; set; } - /// <summary>Optional extended data which isn't needed for update checks.</summary> - public ModExtendedMetadataModel Metadata { get; set; } - /// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary> - public bool HasBetaInfo { get; set; } + [Obsolete] + public bool? HasBetaInfo { get; set; } /// <summary>The errors that occurred while fetching update data.</summary> public string[] Errors { get; set; } = new string[0]; diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs index 989c18b0..4a697585 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModExtendedMetadataModel.cs @@ -28,6 +28,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The mod ID in the Chucklefish mod repo.</summary> public int? ChucklefishID { get; set; } + /// <summary>The mod ID in the CurseForge mod repo.</summary> + 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; } + /// <summary>The mod ID in the ModDrop mod repo.</summary> public int? ModDropID { get; set; } @@ -40,6 +46,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The custom mod page URL (if applicable).</summary> public string CustomUrl { get; set; } + /// <summary>The main version.</summary> + public ModEntryVersionModel Main { get; set; } + + /// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary> + 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; } + + /// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary> + public ModEntryVersionModel UnofficialForBeta { get; set; } /**** ** Stable compatibility @@ -48,13 +65,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi [JsonConverter(typeof(StringEnumConverter))] public WikiCompatibilityStatus? CompatibilityStatus { get; set; } - /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatitng.</summary> + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> public string CompatibilitySummary { get; set; } /// <summary>The game or SMAPI version which broke this mod, if applicable.</summary> public string BrokeIn { get; set; } - /**** ** Beta compatibility ****/ @@ -62,7 +78,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi [JsonConverter(typeof(StringEnumConverter))] public WikiCompatibilityStatus? BetaCompatibilityStatus { get; set; } - /// <summary>The human-readable summary of the compatibility status or workaround for the Stardew Valley beta (if any), without HTML formatitng.</summary> + /// <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; } /// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary> @@ -78,8 +94,18 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Construct an instance.</summary> /// <param name="wiki">The mod metadata from the wiki (if available).</param> /// <param name="db">The mod metadata from SMAPI's internal DB (if available).</param> - public ModExtendedMetadataModel(WikiModEntry wiki, ModDataRecord db) + /// <param name="main">The main version.</param> + /// <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) { + // versions + this.Main = main; + this.Optional = optional; + this.Unofficial = unofficial; + this.UnofficialForBeta = unofficialForBeta; + // wiki data if (wiki != null) { @@ -87,6 +113,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi this.Name = wiki.Name.FirstOrDefault(); this.NexusID = wiki.NexusID; this.ChucklefishID = wiki.ChucklefishID; + this.CurseForgeID = wiki.CurseForgeID; + this.CurseForgeKey = wiki.CurseForgeKey; this.ModDropID = wiki.ModDropID; this.GitHubRepo = wiki.GitHubRepo; this.CustomSourceUrl = wiki.CustomSourceUrl; diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs deleted file mode 100644 index e352e1cc..00000000 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSeachModel.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Linq; - -namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi -{ - /// <summary>Specifies mods whose update-check info to fetch.</summary> - public class ModSearchModel - { - /********* - ** Accessors - *********/ - /// <summary>The mods for which to find data.</summary> - public ModSearchEntryModel[] Mods { get; set; } - - /// <summary>Whether to include extended metadata for each mod.</summary> - public bool IncludeExtendedMetadata { get; set; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an empty instance.</summary> - public ModSearchModel() - { - // needed for JSON deserialising - } - - /// <summary>Construct an instance.</summary> - /// <param name="mods">The mods to search.</param> - /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> - public ModSearchModel(ModSearchEntryModel[] mods, bool includeExtendedMetadata) - { - this.Mods = mods.ToArray(); - this.IncludeExtendedMetadata = includeExtendedMetadata; - } - } -} diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs index bca47647..bf81e102 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchEntryModel.cs @@ -12,6 +12,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>The namespaced mod update keys (if available).</summary> public string[] UpdateKeys { get; 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; } + + /// <summary>Whether the installed version is broken or could not be loaded.</summary> + public bool IsBroken { get; set; } + /********* ** Public methods @@ -19,15 +25,18 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Construct an empty instance.</summary> public ModSearchEntryModel() { - // needed for JSON deserialising + // 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> - public ModSearchEntryModel(string id, string[] updateKeys) + /// <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) { this.ID = id; + this.InstalledVersion = installedVersion; this.UpdateKeys = updateKeys ?? new string[0]; } } diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs new file mode 100644 index 00000000..73698173 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/ModSearchModel.cs @@ -0,0 +1,52 @@ +using System.Linq; +using StardewModdingAPI.Toolkit.Utilities; + +namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi +{ + /// <summary>Specifies mods whose update-check info to fetch.</summary> + public class ModSearchModel + { + /********* + ** Accessors + *********/ + /// <summary>The mods for which to find data.</summary> + public ModSearchEntryModel[] Mods { get; set; } + + /// <summary>Whether to include extended metadata for each mod.</summary> + public bool IncludeExtendedMetadata { get; set; } + + /// <summary>The SMAPI version installed by the player. This is used for version mapping in some cases.</summary> + public ISemanticVersion ApiVersion { get; set; } + + /// <summary>The Stardew Valley version installed by the player.</summary> + public ISemanticVersion GameVersion { get; set; } + + /// <summary>The OS on which the player plays.</summary> + public Platform? Platform { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an empty instance.</summary> + public ModSearchModel() + { + // needed for JSON deserializing + } + + /// <summary>Construct an instance.</summary> + /// <param name="mods">The mods to search.</param> + /// <param name="apiVersion">The SMAPI version installed by the player. If this is null, the API won't provide a recommended update.</param> + /// <param name="gameVersion">The Stardew Valley version installed by the player.</param> + /// <param name="platform">The OS on which the player plays.</param> + /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> + public ModSearchModel(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata) + { + this.Mods = mods.ToArray(); + this.ApiVersion = apiVersion; + this.GameVersion = gameVersion; + this.Platform = platform; + this.IncludeExtendedMetadata = includeExtendedMetadata; + } + } +} diff --git a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs index 7c3df384..f0a7c82a 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/WebApi/WebApiClient.cs @@ -3,7 +3,8 @@ using System.Collections.Generic; using System.Linq; using System.Net; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi { @@ -37,12 +38,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi /// <summary>Get metadata about a set of mods from the web API.</summary> /// <param name="mods">The mod keys for which to fetch the latest version.</param> + /// <param name="apiVersion">The SMAPI version installed by the player. If this is null, the API won't provide a recommended update.</param> + /// <param name="gameVersion">The Stardew Valley version installed by the player.</param> + /// <param name="platform">The OS on which the player plays.</param> /// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param> - public IDictionary<string, ModEntryModel> GetModInfo(ModSearchEntryModel[] mods, bool includeExtendedMetadata = false) + public IDictionary<string, ModEntryModel> GetModInfo(ModSearchEntryModel[] mods, ISemanticVersion apiVersion, ISemanticVersion gameVersion, Platform platform, bool includeExtendedMetadata = false) { return this.Post<ModSearchModel, ModEntryModel[]>( $"v{this.Version}/mods", - new ModSearchModel(mods, includeExtendedMetadata) + new ModSearchModel(mods, apiVersion, gameVersion, platform, includeExtendedMetadata) ).ToDictionary(p => p.ID); } diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs index 3e9b8ea6..384f23fc 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiClient.cs @@ -93,12 +93,17 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki string[] warnings = this.GetAttributeAsCsv(node, "data-warnings"); 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"); 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"); + IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions"); + IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions"); // parse stable compatibility WikiCompatibilityInfo compatibility = new WikiCompatibilityInfo @@ -127,6 +132,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki } } + // parse links + List<Tuple<Uri, string>> metadataLinks = new List<Tuple<Uri, string>>(); + foreach (HtmlNode linkElement in node.Descendants("td").Last().Descendants("a").Skip(1)) // skip anchor link + { + string text = linkElement.InnerText.Trim(); + Uri url = new Uri(linkElement.GetAttributeValue("href", "")); + metadataLinks.Add(Tuple.Create(url, text)); + } + // yield model yield return new WikiModEntry { @@ -135,6 +149,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Author = authors, NexusID = nexusID, ChucklefishID = chucklefishID, + CurseForgeID = curseForgeID, + CurseForgeKey = curseForgeKey, ModDropID = modDropID, GitHubRepo = githubRepo, CustomSourceUrl = customSourceUrl, @@ -143,6 +159,10 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki Compatibility = compatibility, BetaCompatibility = betaCompatibility, Warnings = warnings, + MetadataLinks = metadataLinks.ToArray(), + DevNote = devNote, + MapLocalVersions = mapLocalVersions, + MapRemoteVersions = mapRemoteVersions, Anchor = anchor }; } @@ -207,6 +227,28 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki return null; } + /// <summary>Get an attribute value and parse it as a version mapping.</summary> + /// <param name="element">The element whose attributes to read.</param> + /// <param name="name">The attribute name.</param> + private IDictionary<string, string> GetAttributeAsVersionMapping(HtmlNode element, string name) + { + // get raw value + string raw = this.GetAttribute(element, name); + if (raw?.Contains("→") != true) + return null; + + // parse + // Specified on the wiki in the form "remote version → mapped version; another remote version → mapped version" + IDictionary<string, string> map = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string pair in raw.Split(';')) + { + string[] versions = pair.Split('→'); + if (versions.Length == 2 && !string.IsNullOrWhiteSpace(versions[0]) && !string.IsNullOrWhiteSpace(versions[1])) + map[versions[0].Trim()] = versions[1].Trim(); + } + return map; + } + /// <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> diff --git a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs index cf416cc6..931dcd43 100644 --- a/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs +++ b/src/SMAPI.Toolkit/Framework/Clients/Wiki/WikiModEntry.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; + namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki { /// <summary>A mod entry in the wiki list.</summary> @@ -21,6 +24,12 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>The mod ID in the Chucklefish mod repo.</summary> public int? ChucklefishID { get; set; } + /// <summary>The mod ID in the CurseForge mod repo.</summary> + 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; } + /// <summary>The mod ID in the ModDrop mod repo.</summary> public int? ModDropID { get; set; } @@ -48,6 +57,18 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } + /// <summary>Extra metadata links (usually for open pull requests).</summary> + public Tuple<Uri, string>[] MetadataLinks { get; set; } + + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> + public string DevNote { get; set; } + + /// <summary>Maps local versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + public IDictionary<string, string> MapRemoteVersions { get; set; } + /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> public string Anchor { get; set; } } diff --git a/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs new file mode 100644 index 00000000..212c70ef --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/GameScanning/GameScanner.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using System.Xml.XPath; +using StardewModdingAPI.Toolkit.Utilities; +#if SMAPI_FOR_WINDOWS +using Microsoft.Win32; +#endif + +namespace StardewModdingAPI.Toolkit.Framework.GameScanning +{ + /// <summary>Finds installed game folders.</summary> + public class GameScanner + { + /********* + ** Public methods + *********/ + /// <summary>Find all valid Stardew Valley install folders.</summary> + /// <remarks>This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS.</remarks> + public IEnumerable<DirectoryInfo> Scan() + { + // get OS info + Platform platform = EnvironmentUtility.DetectPlatform(); + string executableFilename = EnvironmentUtility.GetExecutableName(platform); + + // get install paths + IEnumerable<string> paths = this + .GetCustomInstallPaths(platform) + .Concat(this.GetDefaultInstallPaths(platform)) + .Select(PathUtilities.NormalizePathSeparators) + .Distinct(StringComparer.InvariantCultureIgnoreCase); + + // yield valid folders + foreach (string path in paths) + { + DirectoryInfo folder = new DirectoryInfo(path); + if (folder.Exists && folder.EnumerateFiles(executableFilename).Any()) + yield return folder; + } + } + + + /********* + ** Private methods + *********/ + /// <summary>The default file paths where Stardew Valley can be installed.</summary> + /// <param name="platform">The target platform.</param> + /// <remarks>Derived from the crossplatform mod config: https://github.com/Pathoschild/Stardew.ModBuildConfig. </remarks> + private IEnumerable<string> GetDefaultInstallPaths(Platform platform) + { + switch (platform) + { + case Platform.Linux: + case Platform.Mac: + { + string home = Environment.GetEnvironmentVariable("HOME"); + + // Linux + yield return $"{home}/GOG Games/Stardew Valley/game"; + yield return Directory.Exists($"{home}/.steam/steam/steamapps/common/Stardew Valley") + ? $"{home}/.steam/steam/steamapps/common/Stardew Valley" + : $"{home}/.local/share/Steam/steamapps/common/Stardew Valley"; + + // Mac + yield return "/Applications/Stardew Valley.app/Contents/MacOS"; + yield return $"{home}/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS"; + } + break; + + case Platform.Windows: + { + // Windows + foreach (string programFiles in new[] { @"C:\Program Files", @"C:\Program Files (x86)" }) + { + yield return $@"{programFiles}\GalaxyClient\Games\Stardew Valley"; + yield return $@"{programFiles}\GOG Galaxy\Games\Stardew Valley"; + yield return $@"{programFiles}\Steam\steamapps\common\Stardew Valley"; + } + + // Windows registry +#if SMAPI_FOR_WINDOWS + IDictionary<string, string> registryKeys = new Dictionary<string, string> + { + [@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150"] = "InstallLocation", // Steam + [@"SOFTWARE\WOW6432Node\GOG.com\Games\1453375253"] = "PATH", // GOG on 64-bit Windows + }; + foreach (var pair in registryKeys) + { + string path = this.GetLocalMachineRegistryValue(pair.Key, pair.Value); + if (!string.IsNullOrWhiteSpace(path)) + yield return path; + } + + // via Steam library path + string steampath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath"); + if (steampath != null) + yield return Path.Combine(steampath.Replace('/', '\\'), @"steamapps\common\Stardew Valley"); +#endif + } + break; + + default: + throw new InvalidOperationException($"Unknown platform '{platform}'."); + } + } + + /// <summary>Get the custom install path from the <c>stardewvalley.targets</c> file in the home directory, if any.</summary> + /// <param name="platform">The target platform.</param> + private IEnumerable<string> GetCustomInstallPaths(Platform platform) + { + // get home path + string homePath = Environment.GetEnvironmentVariable(platform == Platform.Windows ? "USERPROFILE" : "HOME"); + if (string.IsNullOrWhiteSpace(homePath)) + yield break; + + // get targets file + FileInfo file = new FileInfo(Path.Combine(homePath, "stardewvalley.targets")); + if (!file.Exists) + yield break; + + // parse file + XElement root; + try + { + using (FileStream stream = file.OpenRead()) + root = XElement.Load(stream); + } + catch + { + yield break; + } + + // get install path + 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(); + } + +#if SMAPI_FOR_WINDOWS + /// <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) + { + RegistryKey localMachine = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64) : Registry.LocalMachine; + RegistryKey openKey = localMachine.OpenSubKey(key); + if (openKey == null) + return null; + using (openKey) + return (string)openKey.GetValue(name); + } + + /// <summary>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) + { + 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); + } +#endif + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs index 18039762..8b40c301 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataModel.cs @@ -25,12 +25,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// </remarks> public string FormerIDs { get; set; } - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; set; } = new Dictionary<string, string>(); - - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; set; } = new Dictionary<string, string>(); - /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary> public ModWarning SuppressWarnings { get; set; } @@ -112,8 +106,8 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /********* ** Private methods *********/ - /// <summary>The method invoked after JSON deserialisation.</summary> - /// <param name="context">The deserialisation context.</param> + /// <summary>The method invoked after JSON deserialization.</summary> + /// <param name="context">The deserialization context.</param> [OnDeserialized] private void OnDeserialized(StreamingContext context) { diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs index 794ad2e4..c892d820 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecord.cs @@ -22,12 +22,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>The mod warnings to suppress, even if they'd normally be shown.</summary> public ModWarning SuppressWarnings { get; set; } - /// <summary>Maps local versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapLocalVersions { get; } - - /// <summary>Maps remote versions to a semantic version for update checks.</summary> - public IDictionary<string, string> MapRemoteVersions { get; } - /// <summary>The versioned field data.</summary> public ModDataField[] Fields { get; } @@ -44,8 +38,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData this.ID = model.ID; this.FormerIDs = model.GetFormerIDs().ToArray(); this.SuppressWarnings = model.SuppressWarnings; - this.MapLocalVersions = new Dictionary<string, string>(model.MapLocalVersions, StringComparer.InvariantCultureIgnoreCase); - this.MapRemoteVersions = new Dictionary<string, string>(model.MapRemoteVersions, StringComparer.InvariantCultureIgnoreCase); this.Fields = model.GetFields().ToArray(); } @@ -67,29 +59,6 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData return false; } - /// <summary>Get a semantic local version for update checks.</summary> - /// <param name="version">The remote version to normalise.</param> - public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version) - { - return this.MapLocalVersions != null && this.MapLocalVersions.TryGetValue(version.ToString(), out string newVersion) - ? new SemanticVersion(newVersion) - : version; - } - - /// <summary>Get a semantic remote version for update checks.</summary> - /// <param name="version">The remote version to normalise.</param> - public string GetRemoteVersionForUpdateChecks(string version) - { - // normalise version if possible - if (SemanticVersion.TryParse(version, out ISemanticVersion parsed)) - version = parsed.ToString(); - - // fetch remote version - return this.MapRemoteVersions != null && this.MapRemoteVersions.TryGetValue(version, out string newVersion) - ? newVersion - : version; - } - /// <summary>Get the possible mod IDs.</summary> public IEnumerable<string> GetIDs() { diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs index 237f2c66..598da66a 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModDataRecordVersionedFields.cs @@ -26,29 +26,5 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>The upper version for which the <see cref="Status"/> applies (if any).</summary> public ISemanticVersion StatusUpperVersion { get; set; } - - - /********* - ** Public methods - *********/ - /// <summary>Get a semantic local version for update checks.</summary> - /// <param name="version">The remote version to normalise.</param> - public ISemanticVersion GetLocalVersionForUpdateChecks(ISemanticVersion version) - { - return this.DataRecord.GetLocalVersionForUpdateChecks(version); - } - - /// <summary>Get a semantic remote version for update checks.</summary> - /// <param name="version">The remote version to normalise.</param> - public ISemanticVersion GetRemoteVersionForUpdateChecks(ISemanticVersion version) - { - if (version == null) - return null; - - string rawVersion = this.DataRecord.GetRemoteVersionForUpdateChecks(version.ToString()); - return rawVersion != null - ? new SemanticVersion(rawVersion) - : version; - } } } diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs index d61c427f..e67616d0 100644 --- a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs +++ b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData BrokenCodeLoaded = 1, /// <summary>The mod affects the save serializer in a way that may make saves unloadable without the mod.</summary> - ChangesSaveSerialiser = 2, + ChangesSaveSerializer = 2, /// <summary>The mod patches the game in a way that may impact stability.</summary> PatchesGame = 4, @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData /// <summary>The mod uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> UsesDynamic = 8, - /// <summary>The mod references specialised 'unvalided update tick' events which may impact stability.</summary> + /// <summary>The mod references specialized 'unvalidated update tick' events which may impact stability.</summary> UsesUnvalidatedUpdateTick = 16, /// <summary>The mod has no update keys set.</summary> diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs index bb467b36..d0df09a1 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModFolder.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Toolkit.Framework.ModScanning @@ -18,14 +18,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>The folder containing the mod's manifest.json.</summary> public DirectoryInfo Directory { get; } + /// <summary>The mod type.</summary> + public ModType Type { get; } + /// <summary>The mod manifest.</summary> public Manifest Manifest { get; } /// <summary>The error which occurred parsing the manifest, if any.</summary> - public string ManifestParseError { get; } + public ModParseError ManifestParseError { get; set; } - /// <summary>Whether the mod should be loaded by default. This is <c>false</c> if it was found within a folder whose name starts with a dot.</summary> - public bool ShouldBeLoaded { get; } + /// <summary>A human-readable message for the <see cref="ManifestParseError"/>, if any.</summary> + public string ManifestParseErrorText { get; set; } /********* @@ -34,16 +37,26 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>Construct an instance.</summary> /// <param name="root">The root folder containing mods.</param> /// <param name="directory">The folder containing the mod's manifest.json.</param> + /// <param name="type">The mod type.</param> + /// <param name="manifest">The mod manifest.</param> + public ModFolder(DirectoryInfo root, DirectoryInfo directory, ModType type, Manifest manifest) + : this(root, directory, type, manifest, ModParseError.None, null) { } + + /// <summary>Construct an instance.</summary> + /// <param name="root">The root folder containing mods.</param> + /// <param name="directory">The folder containing the mod's manifest.json.</param> + /// <param name="type">The mod type.</param> /// <param name="manifest">The mod manifest.</param> /// <param name="manifestParseError">The error which occurred parsing the manifest, if any.</param> - /// <param name="shouldBeLoaded">Whether the mod should be loaded by default. This should be <c>false</c> if it was found within a folder whose name starts with a dot.</param> - public ModFolder(DirectoryInfo root, DirectoryInfo directory, Manifest manifest, string manifestParseError = null, bool shouldBeLoaded = true) + /// <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) { // save info this.Directory = directory; + this.Type = type; this.Manifest = manifest; this.ManifestParseError = manifestParseError; - this.ShouldBeLoaded = shouldBeLoaded; + this.ManifestParseErrorText = manifestParseErrorText; // set display name this.DisplayName = manifest?.Name; diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs new file mode 100644 index 00000000..b10510ff --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModParseError.cs @@ -0,0 +1,24 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// <summary>Indicates why a mod could not be parsed.</summary> + public enum ModParseError + { + /// <summary>No parse error.</summary> + None, + + /// <summary>The folder is empty or contains only ignored files.</summary> + EmptyFolder, + + /// <summary>The folder is ignored by convention.</summary> + IgnoredFolder, + + /// <summary>The mod's <c>manifest.json</c> could not be parsed.</summary> + ManifestInvalid, + + /// <summary>The folder contains non-ignored and non-XNB files, but none of them are <c>manifest.json</c>.</summary> + ManifestMissing, + + /// <summary>The folder is an XNB mod, which can't be loaded through SMAPI.</summary> + XnbMod + } +} diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs index 0ab73d56..f11cc1a7 100644 --- a/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModScanner.cs @@ -2,8 +2,9 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI.Toolkit.Framework.ModScanning { @@ -17,20 +18,32 @@ 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<string> IgnoreFilesystemEntries = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + private readonly HashSet<Regex> IgnoreFilesystemEntries = new HashSet<Regex> { - ".DS_Store", - "mcs", - "Thumbs.db" + // OS metadata files + new Regex(@"^__folder_managed_by_vortex$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Vortex mod manager + new Regex(@"^(?:__MACOSX|\._\.DS_Store|\.DS_Store|mcs)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // MacOS + new Regex(@"^(?:desktop\.ini|Thumbs\.db)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows + new Regex(@"\.(?:url|lnk)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // Windows shortcut files + + // other + new Regex(@"\.(?:bmp|gif|jpeg|jpg|png|psd|tif)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // image files + new Regex(@"\.(?:md|rtf|txt)$", RegexOptions.Compiled | RegexOptions.IgnoreCase), // text files + new Regex(@"\.(?:backup|bak|old)$", RegexOptions.Compiled | RegexOptions.IgnoreCase) // backup file }; - /// <summary>The extensions for files which an XNB mod may contain. If a mod contains *only* these file extensions, it should be considered an XNB mod.</summary> + /// <summary>The extensions for files which an XNB mod may contain. If a mod doesn't have a <c>manifest.json</c> and contains *only* these file extensions, it should be considered an XNB mod.</summary> private readonly HashSet<string> PotentialXnbModExtensions = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) { - ".md", - ".png", - ".txt", - ".xnb" + // XNB files + ".xgs", + ".xnb", + ".xsb", + ".xwb", + + // unpacking artifacts + ".json", + ".yaml" }; @@ -52,6 +65,15 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return this.GetModFolders(root, root); } + /// <summary>Extract information about all mods in the given folder.</summary> + /// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param> + /// <param name="modPath">The mod path to search.</param> + // /// <param name="tryConsolidateMod">If the folder contains multiple XNB mods, treat them as subfolders of a single mod. This is useful when reading a single mod archive, as opposed to a mods folder.</param> + public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath) + { + return this.GetModFolders(root: new DirectoryInfo(rootPath), folder: new DirectoryInfo(modPath)); + } + /// <summary>Extract information from a mod folder.</summary> /// <param name="root">The root folder containing mods.</param> /// <param name="searchFolder">The folder to search for a mod.</param> @@ -63,34 +85,40 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning // set appropriate invalid-mod error if (manifestFile == null) { - FileInfo[] files = searchFolder.GetFiles("*", SearchOption.AllDirectories).Where(this.IsRelevant).ToArray(); + FileInfo[] files = this.RecursivelyGetRelevantFiles(searchFolder).ToArray(); if (!files.Any()) - return new ModFolder(root, searchFolder, null, "it's an empty folder."); - if (files.All(file => this.PotentialXnbModExtensions.Contains(file.Extension))) - return new ModFolder(root, searchFolder, null, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); - return new ModFolder(root, searchFolder, null, "it contains files, but none of them are manifest.json."); + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.EmptyFolder, "it's an empty folder."); + if (files.All(this.IsPotentialXnbFile)) + return new ModFolder(root, searchFolder, ModType.Xnb, null, ModParseError.XnbMod, "it's not a SMAPI mod (see https://smapi.io/xnb for info)."); + return new ModFolder(root, searchFolder, ModType.Invalid, null, ModParseError.ManifestMissing, "it contains files, but none of them are manifest.json."); } // read mod info Manifest manifest = null; - string manifestError = null; + ModParseError error = ModParseError.None; + string errorText = null; { try { if (!this.JsonHelper.ReadJsonFileIfExists<Manifest>(manifestFile.FullName, out manifest) || manifest == null) - manifestError = "its manifest is invalid."; + { + error = ModParseError.ManifestInvalid; + errorText = "its manifest is invalid."; + } } catch (SParseException ex) { - manifestError = $"parsing its manifest failed: {ex.Message}"; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed: {ex.Message}"; } catch (Exception ex) { - manifestError = $"parsing its manifest failed:\n{ex}"; + error = ModParseError.ManifestInvalid; + errorText = $"parsing its manifest failed:\n{ex}"; } } - // normalise display fields + // normalize display fields if (manifest != null) { manifest.Name = this.StripNewlines(manifest.Name); @@ -98,7 +126,17 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning manifest.Author = this.StripNewlines(manifest.Author); } - return new ModFolder(root, manifestFile.Directory, manifest, manifestError); + // get mod type + ModType type = ModType.Invalid; + if (manifest != null) + { + type = !string.IsNullOrWhiteSpace(manifest.ContentPackFor?.UniqueID) + ? ModType.ContentPack + : ModType.Smapi; + } + + // build result + return new ModFolder(root, manifestFile.Directory, type, manifest, error, errorText); } @@ -108,20 +146,30 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning /// <summary>Recursively extract information about all mods in the given folder.</summary> /// <param name="root">The root mod folder.</param> /// <param name="folder">The folder to search for mods.</param> - public IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) + private IEnumerable<ModFolder> GetModFolders(DirectoryInfo root, DirectoryInfo folder) { - // skip - if (folder.FullName != root.FullName && folder.Name.StartsWith(".")) - yield return new ModFolder(root, folder, null, "ignored folder because its name starts with a dot.", shouldBeLoaded: false); + bool isRoot = folder.FullName == root.FullName; - // recurse into subfolders - else if (this.IsModSearchFolder(root, folder)) + // skip + if (!isRoot) { - foreach (DirectoryInfo subfolder in folder.EnumerateDirectories()) + if (folder.Name.StartsWith(".")) { - foreach (ModFolder match in this.GetModFolders(root, subfolder)) - yield return match; + yield return new ModFolder(root, folder, ModType.Ignored, null, ModParseError.IgnoredFolder, "ignored folder because its name starts with a dot."); + yield break; } + if (!this.IsRelevant(folder)) + yield break; + } + + // find mods in subfolders + if (this.IsModSearchFolder(root, folder)) + { + IEnumerable<ModFolder> subfolders = folder.EnumerateDirectories().SelectMany(sub => this.GetModFolders(root, sub)); + if (!isRoot) + subfolders = this.TryConsolidate(root, folder, subfolders.ToArray()); + foreach (ModFolder subfolder in subfolders) + yield return subfolder; } // treat as mod folder @@ -129,6 +177,26 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning yield return this.ReadFolder(root, folder); } + /// <summary>Consolidate adjacent folders into one mod folder, if possible.</summary> + /// <param name="root">The folder containing both parent and subfolders.</param> + /// <param name="parentFolder">The parent folder to consolidate, if possible.</param> + /// <param name="subfolders">The subfolders to consolidate, if possible.</param> + private IEnumerable<ModFolder> TryConsolidate(DirectoryInfo root, DirectoryInfo parentFolder, ModFolder[] subfolders) + { + if (subfolders.Length > 1) + { + // a collection of empty folders + if (subfolders.All(p => p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Invalid, null, ModParseError.EmptyFolder, subfolders[0].ManifestParseErrorText) }; + + // an XNB mod + if (subfolders.All(p => p.Type == ModType.Xnb || p.ManifestParseError == ModParseError.EmptyFolder)) + return new[] { new ModFolder(root, parentFolder, ModType.Xnb, null, ModParseError.XnbMod, subfolders[0].ManifestParseErrorText) }; + } + + return subfolders; + } + /// <summary>Find the manifest for a mod folder.</summary> /// <param name="folder">The folder to search.</param> private FileInfo FindManifest(DirectoryInfo folder) @@ -166,11 +234,41 @@ namespace StardewModdingAPI.Toolkit.Framework.ModScanning return subfolders.Any() && !files.Any(); } + /// <summary>Recursively get all relevant files in a folder based on the result of <see cref="IsRelevant"/>.</summary> + /// <param name="folder">The root folder to search.</param> + private IEnumerable<FileInfo> RecursivelyGetRelevantFiles(DirectoryInfo folder) + { + foreach (FileSystemInfo entry in folder.GetFileSystemInfos()) + { + if (!this.IsRelevant(entry)) + continue; + + if (entry is FileInfo file) + yield return file; + + if (entry is DirectoryInfo subfolder) + { + foreach (FileInfo subfolderFile in this.RecursivelyGetRelevantFiles(subfolder)) + yield return subfolderFile; + } + } + } + /// <summary>Get whether a file or folder is relevant when deciding how to process a mod folder.</summary> /// <param name="entry">The file or folder.</param> private bool IsRelevant(FileSystemInfo entry) { - return !this.IgnoreFilesystemEntries.Contains(entry.Name); + return !this.IgnoreFilesystemEntries.Any(p => p.IsMatch(entry.Name)); + } + + /// <summary>Get whether a file is potentially part of an XNB mod.</summary> + /// <param name="entry">The file.</param> + private bool IsPotentialXnbFile(FileInfo entry) + { + if (!this.IsRelevant(entry)) + return true; + + return this.PotentialXnbModExtensions.Contains(entry.Extension); // use EndsWith to handle cases like image..png } /// <summary>Strip newlines from a string.</summary> diff --git a/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs new file mode 100644 index 00000000..bc86edb6 --- /dev/null +++ b/src/SMAPI.Toolkit/Framework/ModScanning/ModType.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Toolkit.Framework.ModScanning +{ + /// <summary>A general mod type.</summary> + public enum ModType + { + /// <summary>The mod is invalid and its type could not be determined.</summary> + Invalid, + + /// <summary>The folder is ignored by convention.</summary> + Ignored, + + /// <summary>A mod which uses SMAPI directly.</summary> + Smapi, + + /// <summary>A mod which contains files loaded by a SMAPI mod.</summary> + ContentPack, + + /// <summary>A legacy mod which replaces game files directly.</summary> + Xnb + } +} diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs index f6c402d5..765ca334 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/ModRepositoryKey.cs @@ -9,6 +9,9 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData /// <summary>The Chucklefish mod repository.</summary> Chucklefish, + /// <summary>The CurseForge mod repository.</summary> + CurseForge, + /// <summary>A GitHub project containing releases.</summary> GitHub, diff --git a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs index 865ebcf7..3fc1759e 100644 --- a/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs +++ b/src/SMAPI.Toolkit/Framework/UpdateData/UpdateKey.cs @@ -3,7 +3,7 @@ using System; namespace StardewModdingAPI.Toolkit.Framework.UpdateData { /// <summary>A namespaced mod ID which uniquely identifies a mod within a mod repository.</summary> - public class UpdateKey + public class UpdateKey : IEquatable<UpdateKey> { /********* ** Accessors @@ -38,6 +38,12 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData && !string.IsNullOrWhiteSpace(id); } + /// <summary>Construct an instance.</summary> + /// <param name="repository">The mod repository containing the mod.</param> + /// <param name="id">The mod ID within the repository.</param> + public UpdateKey(ModRepositoryKey repository, string id) + : this($"{repository}:{id}", repository, id) { } + /// <summary>Parse a raw update key.</summary> /// <param name="raw">The raw update key to parse.</param> public static UpdateKey Parse(string raw) @@ -69,5 +75,29 @@ namespace StardewModdingAPI.Toolkit.Framework.UpdateData ? $"{this.Repository}:{this.ID}" : this.RawText; } + + /// <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) + { + return + other != null + && this.Repository == other.Repository + && string.Equals(this.ID, other.ID, StringComparison.InvariantCultureIgnoreCase); + } + + /// <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) + { + return obj is UpdateKey other && this.Equals(other); + } + + /// <summary>Serves as the default hash function. </summary> + /// <returns>A hash code for the current object.</returns> + public override int GetHashCode() + { + return $"{this.Repository}:{this.ID}".ToLower().GetHashCode(); + } } } diff --git a/src/SMAPI.Toolkit/ModToolkit.cs b/src/SMAPI.Toolkit/ModToolkit.cs index 1b53e59e..80b14659 100644 --- a/src/SMAPI.Toolkit/ModToolkit.cs +++ b/src/SMAPI.Toolkit/ModToolkit.cs @@ -2,13 +2,17 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading.Tasks; using Newtonsoft.Json; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Toolkit.Framework.GameScanning; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; +[assembly: InternalsVisibleTo("StardewModdingAPI")] +[assembly: InternalsVisibleTo("SMAPI.Web")] namespace StardewModdingAPI.Toolkit { /// <summary>A convenience wrapper for the various tools.</summary> @@ -46,6 +50,13 @@ namespace StardewModdingAPI.Toolkit this.UserAgent = $"SMAPI Mod Handler Toolkit/{version}"; } + /// <summary>Find valid Stardew Valley install folders.</summary> + /// <remarks>This checks default game locations, and on Windows checks the Windows registry for GOG/Steam install data. A folder is considered 'valid' if it contains the Stardew Valley executable for the current OS.</remarks> + public IEnumerable<DirectoryInfo> GetGameFolders() + { + return new GameScanner().Scan(); + } + /// <summary>Extract mod metadata from the wiki compatibility list.</summary> public async Task<WikiModList> GetWikiCompatibilityListAsync() { @@ -69,6 +80,14 @@ namespace StardewModdingAPI.Toolkit return new ModScanner(this.JsonHelper).GetModFolders(rootPath); } + /// <summary>Extract information about all mods in the given folder.</summary> + /// <param name="rootPath">The root folder containing mods. Only the <paramref name="modPath"/> will be searched, but this field allows it to be treated as a potential mod folder of its own.</param> + /// <param name="modPath">The mod path to search.</param> + public IEnumerable<ModFolder> GetModFolders(string rootPath, string modPath) + { + return new ModScanner(this.JsonHelper).GetModFolders(rootPath, modPath); + } + /// <summary>Get an update URL for an update key (if valid).</summary> /// <param name="updateKey">The update key.</param> public string GetUpdateUrl(string updateKey) diff --git a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs b/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs deleted file mode 100644 index 1bb19e8c..00000000 --- a/src/SMAPI.Toolkit/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -[assembly: AssemblyTitle("SMAPI.Toolkit")] -[assembly: AssemblyDescription("A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.")] -[assembly: InternalsVisibleTo("StardewModdingAPI")] -[assembly: InternalsVisibleTo("StardewModdingAPI.Web")] diff --git a/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj new file mode 100644 index 00000000..3bb7e313 --- /dev/null +++ b/src/SMAPI.Toolkit/SMAPI.Toolkit.csproj @@ -0,0 +1,29 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <AssemblyName>SMAPI.Toolkit</AssemblyName> + <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> + <Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description> + <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> + <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath> + <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile> + <LangVersion>latest</LangVersion> + <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> + <RootNamespace>StardewModdingAPI.Toolkit</RootNamespace> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> + <PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" /> + <PackageReference Include="Microsoft.Win32.Registry" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT' AND '$(TargetFramework)' == 'netstandard2.0'" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> + </ItemGroup> + + <Import Project="..\..\build\common.targets" /> + +</Project> diff --git a/src/SMAPI.Toolkit/SemanticVersion.cs b/src/SMAPI.Toolkit/SemanticVersion.cs index ba9ca6c6..2ce3578e 100644 --- a/src/SMAPI.Toolkit/SemanticVersion.cs +++ b/src/SMAPI.Toolkit/SemanticVersion.cs @@ -42,15 +42,6 @@ namespace StardewModdingAPI.Toolkit /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag { get; } -#if !SMAPI_3_0_STRICT - /// <summary>An optional prerelease tag.</summary> - [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] - public string Build => this.PrereleaseTag; - - /// <summary>Whether the version was parsed from the legacy object format.</summary> - public bool IsLegacyFormat { get; } -#endif - /********* ** Public methods @@ -60,20 +51,12 @@ namespace StardewModdingAPI.Toolkit /// <param name="minor">The minor version incremented for backwards-compatible changes.</param> /// <param name="patch">The patch version for backwards-compatible fixes.</param> /// <param name="prereleaseTag">An optional prerelease tag.</param> - /// <param name="isLegacyFormat">Whether the version was parsed from the legacy object format.</param> - public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null -#if !SMAPI_3_0_STRICT - , bool isLegacyFormat = false -#endif - ) + public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null) { this.MajorVersion = major; this.MinorVersion = minor; this.PatchVersion = patch; - this.PrereleaseTag = this.GetNormalisedTag(prereleaseTag); -#if !SMAPI_3_0_STRICT - this.IsLegacyFormat = isLegacyFormat; -#endif + this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag); this.AssertValid(); } @@ -106,16 +89,16 @@ namespace StardewModdingAPI.Toolkit if (!match.Success) throw new FormatException($"The input '{version}' isn't a valid semantic version."); - // initialise + // initialize this.MajorVersion = int.Parse(match.Groups["major"].Value); this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0; this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0; - this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null; + this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null; this.AssertValid(); } - /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <summary>Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.</summary> /// <param name="other">The version to compare with this instance.</param> /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> public int CompareTo(ISemanticVersion other) @@ -133,7 +116,7 @@ namespace StardewModdingAPI.Toolkit return other != null && this.CompareTo(other) == 0; } - /// <summary>Whether this is a pre-release version.</summary> + /// <summary>Whether this is a prerelease version.</summary> public bool IsPrerelease() { return !string.IsNullOrWhiteSpace(this.PrereleaseTag); @@ -189,16 +172,10 @@ namespace StardewModdingAPI.Toolkit /// <summary>Get a string representation of the version.</summary> public override string ToString() { - // version - string result = this.PatchVersion != 0 - ? $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}" - : $"{this.MajorVersion}.{this.MinorVersion}"; - - // tag - string tag = this.PrereleaseTag; - if (tag != null) - result += $"-{tag}"; - return result; + string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}"; + if (this.PrereleaseTag != null) + version += $"-{this.PrereleaseTag}"; + return version; } /// <summary>Parse a version string without throwing an exception if it fails.</summary> @@ -223,15 +200,15 @@ namespace StardewModdingAPI.Toolkit /********* ** Private methods *********/ - /// <summary>Get a normalised build tag.</summary> - /// <param name="tag">The tag to normalise.</param> - private string GetNormalisedTag(string tag) + /// <summary>Get a normalized build tag.</summary> + /// <param name="tag">The tag to normalize.</param> + private string GetNormalizedTag(string tag) { tag = tag?.Trim(); return !string.IsNullOrWhiteSpace(tag) ? tag : null; } - /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <summary>Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.</summary> /// <param name="otherMajor">The major version to compare with this instance.</param> /// <param name="otherMinor">The minor version to compare with this instance.</param> /// <param name="otherPatch">The patch version to compare with this instance.</param> @@ -252,7 +229,7 @@ namespace StardewModdingAPI.Toolkit if (this.PrereleaseTag == otherTag) return same; - // stable supercedes pre-release + // stable supersedes prerelease bool curIsStable = string.IsNullOrWhiteSpace(this.PrereleaseTag); bool otherIsStable = string.IsNullOrWhiteSpace(otherTag); if (curIsStable) @@ -260,12 +237,12 @@ namespace StardewModdingAPI.Toolkit if (otherIsStable) return curOlder; - // compare two pre-release tag values + // compare two prerelease tag values string[] curParts = this.PrereleaseTag.Split('.', '-'); string[] otherParts = otherTag.Split('.', '-'); for (int i = 0; i < curParts.Length; i++) { - // longer prerelease tag supercedes if otherwise equal + // longer prerelease tag supersedes if otherwise equal if (otherParts.Length <= i) return curNewer; diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs index 232c22a7..5cabe9d8 100644 --- a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestContentPackForConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestContentPackForConverter.cs @@ -1,10 +1,10 @@ using System; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; -namespace StardewModdingAPI.Toolkit.Serialisation.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters { - /// <summary>Handles deserialisation of <see cref="ManifestContentPackFor"/> arrays.</summary> + /// <summary>Handles deserialization of <see cref="ManifestContentPackFor"/> arrays.</summary> public class ManifestContentPackForConverter : JsonConverter { /********* diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs index 0a304ee3..7b88d6b7 100644 --- a/src/SMAPI.Toolkit/Serialisation/Converters/ManifestDependencyArrayConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/ManifestDependencyArrayConverter.cs @@ -2,11 +2,11 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; -namespace StardewModdingAPI.Toolkit.Serialisation.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters { - /// <summary>Handles deserialisation of <see cref="ManifestDependency"/> arrays.</summary> + /// <summary>Handles deserialization of <see cref="ManifestDependency"/> arrays.</summary> internal class ManifestDependencyArrayConverter : JsonConverter { /********* diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs index aca06849..ece4a72e 100644 --- a/src/SMAPI.Toolkit/Serialisation/Converters/SemanticVersionConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SemanticVersionConverter.cs @@ -2,9 +2,9 @@ using System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialisation.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters { - /// <summary>Handles deserialisation of <see cref="ISemanticVersion"/>.</summary> + /// <summary>Handles deserialization of <see cref="ISemanticVersion"/>.</summary> internal class SemanticVersionConverter : JsonConverter { /********* @@ -67,20 +67,8 @@ namespace StardewModdingAPI.Toolkit.Serialisation.Converters int minor = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.MinorVersion)); int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion)); string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag)); -#if !SMAPI_3_0_STRICT - if (string.IsNullOrWhiteSpace(prereleaseTag)) - { - prereleaseTag = obj.ValueIgnoreCase<string>("Build"); - if (prereleaseTag == "0") - prereleaseTag = null; // '0' from incorrect examples in old SMAPI documentation - } -#endif - return new SemanticVersion(major, minor, patch, prereleaseTag -#if !SMAPI_3_0_STRICT - , isLegacyFormat: true -#endif - ); + return new SemanticVersion(major, minor, patch, prereleaseTag); } /// <summary>Read a JSON string.</summary> diff --git a/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs index 5e0b0f4a..549f0c18 100644 --- a/src/SMAPI.Toolkit/Serialisation/Converters/SimpleReadOnlyConverter.cs +++ b/src/SMAPI.Toolkit/Serialization/Converters/SimpleReadOnlyConverter.cs @@ -2,10 +2,10 @@ using System; using Newtonsoft.Json; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialisation.Converters +namespace StardewModdingAPI.Toolkit.Serialization.Converters { - /// <summary>The base implementation for simplified converters which deserialise <typeparamref name="T"/> without overriding serialisation.</summary> - /// <typeparam name="T">The type to deserialise.</typeparam> + /// <summary>The base implementation for simplified converters which deserialize <typeparamref name="T"/> without overriding serialization.</summary> + /// <typeparam name="T">The type to deserialize.</typeparam> internal abstract class SimpleReadOnlyConverter<T> : JsonConverter { /********* diff --git a/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs index 12b2c933..9aba53bf 100644 --- a/src/SMAPI.Toolkit/Serialisation/InternalExtensions.cs +++ b/src/SMAPI.Toolkit/Serialization/InternalExtensions.cs @@ -1,7 +1,7 @@ using System; using Newtonsoft.Json.Linq; -namespace StardewModdingAPI.Toolkit.Serialisation +namespace StardewModdingAPI.Toolkit.Serialization { /// <summary>Provides extension methods for parsing JSON.</summary> public static class JsonExtensions diff --git a/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs index cf2ce0d1..031afbb0 100644 --- a/src/SMAPI.Toolkit/Serialisation/JsonHelper.cs +++ b/src/SMAPI.Toolkit/Serialization/JsonHelper.cs @@ -3,9 +3,9 @@ using System.Collections.Generic; using System.IO; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Toolkit.Serialisation +namespace StardewModdingAPI.Toolkit.Serialization { /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> public class JsonHelper @@ -13,7 +13,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation /********* ** Accessors *********/ - /// <summary>The JSON settings to use when serialising and deserialising files.</summary> + /// <summary>The JSON settings to use when serializing and deserializing files.</summary> public JsonSerializerSettings JsonSettings { get; } = new JsonSerializerSettings { Formatting = Formatting.Indented, @@ -31,7 +31,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation *********/ /// <summary>Read a JSON file.</summary> /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="fullPath">The absolete file path.</param> + /// <param name="fullPath">The absolute file path.</param> /// <param name="result">The parsed content model.</param> /// <returns>Returns false if the file doesn't exist, else true.</returns> /// <exception cref="ArgumentException">The given <paramref name="fullPath"/> is empty or invalid.</exception> @@ -54,10 +54,10 @@ namespace StardewModdingAPI.Toolkit.Serialisation return false; } - // deserialise model + // deserialize model try { - result = this.Deserialise<TModel>(json); + result = this.Deserialize<TModel>(json); return true; } catch (Exception ex) @@ -77,7 +77,7 @@ namespace StardewModdingAPI.Toolkit.Serialisation /// <summary>Save to a JSON file.</summary> /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="fullPath">The absolete file path.</param> + /// <param name="fullPath">The absolute file path.</param> /// <param name="model">The model to save.</param> /// <exception cref="InvalidOperationException">The given path is empty or invalid.</exception> public void WriteJsonFile<TModel>(string fullPath, TModel model) @@ -95,14 +95,14 @@ namespace StardewModdingAPI.Toolkit.Serialisation Directory.CreateDirectory(dir); // write file - string json = this.Serialise(model); + string json = this.Serialize(model); File.WriteAllText(fullPath, json); } /// <summary>Deserialize JSON text if possible.</summary> /// <typeparam name="TModel">The model type.</typeparam> /// <param name="json">The raw JSON text.</param> - public TModel Deserialise<TModel>(string json) + public TModel Deserialize<TModel>(string json) { try { @@ -126,9 +126,9 @@ namespace StardewModdingAPI.Toolkit.Serialisation /// <summary>Serialize a model to JSON text.</summary> /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="model">The model to serialise.</param> + /// <param name="model">The model to serialize.</param> /// <param name="formatting">The formatting to apply.</param> - public string Serialise<TModel>(TModel model, Formatting formatting = Formatting.Indented) + public string Serialize<TModel>(TModel model, Formatting formatting = Formatting.Indented) { return JsonConvert.SerializeObject(model, formatting, this.JsonSettings); } diff --git a/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs index 6cb9496b..99e85cbd 100644 --- a/src/SMAPI.Toolkit/Serialisation/Models/Manifest.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/Manifest.cs @@ -1,8 +1,8 @@ using System.Collections.Generic; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Toolkit.Serialisation.Models +namespace StardewModdingAPI.Toolkit.Serialization.Models { /// <summary>A manifest which describes a mod for SMAPI.</summary> public class Manifest : IManifest diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs index d0e42216..1eb80889 100644 --- a/src/SMAPI.Toolkit/Serialisation/Models/ManifestContentPackFor.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestContentPackFor.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Toolkit.Serialisation.Models +namespace StardewModdingAPI.Toolkit.Serialization.Models { /// <summary>Indicates which mod can read the content pack represented by the containing manifest.</summary> public class ManifestContentPackFor : IManifestContentPackFor diff --git a/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs index 8db58d5d..00f168f4 100644 --- a/src/SMAPI.Toolkit/Serialisation/Models/ManifestDependency.cs +++ b/src/SMAPI.Toolkit/Serialization/Models/ManifestDependency.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Toolkit.Serialisation.Models +namespace StardewModdingAPI.Toolkit.Serialization.Models { /// <summary>A mod dependency listed in a mod manifest.</summary> public class ManifestDependency : IManifestDependency diff --git a/src/SMAPI.Toolkit/Serialisation/SParseException.cs b/src/SMAPI.Toolkit/Serialization/SParseException.cs index 61a7b305..5f58b5b8 100644 --- a/src/SMAPI.Toolkit/Serialisation/SParseException.cs +++ b/src/SMAPI.Toolkit/Serialization/SParseException.cs @@ -1,6 +1,6 @@ using System; -namespace StardewModdingAPI.Toolkit.Serialisation +namespace StardewModdingAPI.Toolkit.Serialization { /// <summary>A format exception which provides a user-facing error message.</summary> internal class SParseException : FormatException diff --git a/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj b/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj deleted file mode 100644 index 46d38f17..00000000 --- a/src/SMAPI.Toolkit/StardewModdingAPI.Toolkit.csproj +++ /dev/null @@ -1,28 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath> - <DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\StardewModdingAPI.Toolkit.xml</DocumentationFile> - <LangVersion>latest</LangVersion> - <PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget> - </PropertyGroup> - - <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> - <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj" /> - </ItemGroup> - - <Import Project="..\..\build\common.targets" /> - -</Project> diff --git a/src/SMAPI.Internal/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs index c4e4678a..6dce5da5 100644 --- a/src/SMAPI.Internal/EnvironmentUtility.cs +++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; #if SMAPI_FOR_WINDOWS @@ -6,14 +7,17 @@ using System.Management; #endif using System.Runtime.InteropServices; -namespace StardewModdingAPI.Internal +namespace StardewModdingAPI.Toolkit.Utilities { /// <summary>Provides methods for fetching environment information.</summary> - internal static class EnvironmentUtility + public static class EnvironmentUtility { /********* ** Fields *********/ + /// <summary>The cached platform.</summary> + private static Platform? CachedPlatform; + /// <summary>Get the OS name from the system uname command.</summary> /// <param name="buffer">The buffer to fill with the resulting string.</param> [DllImport("libc")] @@ -26,22 +30,13 @@ namespace StardewModdingAPI.Internal /// <summary>Detect the current OS.</summary> public static Platform DetectPlatform() { - switch (Environment.OSVersion.Platform) - { - case PlatformID.MacOSX: - return Platform.Mac; + if (EnvironmentUtility.CachedPlatform == null) + EnvironmentUtility.CachedPlatform = EnvironmentUtility.DetectPlatformImpl(); - case PlatformID.Unix: - return EnvironmentUtility.IsRunningMac() - ? Platform.Mac - : Platform.Linux; - - default: - return Platform.Windows; - } + return EnvironmentUtility.CachedPlatform.Value; } - + /// <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.")] @@ -77,9 +72,59 @@ namespace StardewModdingAPI.Internal return platform == Platform.Linux || platform == Platform.Mac; } + /********* ** Private methods *********/ + /// <summary>Detect the current OS.</summary> + private static Platform DetectPlatformImpl() + { + switch (Environment.OSVersion.Platform) + { + case PlatformID.MacOSX: + return Platform.Mac; + + case PlatformID.Unix when EnvironmentUtility.IsRunningAndroid(): + return Platform.Android; + + case PlatformID.Unix when EnvironmentUtility.IsRunningMac(): + return Platform.Mac; + + case PlatformID.Unix: + return Platform.Linux; + + default: + return Platform.Windows; + } + } + + /// <summary>Detect whether the code is running on Android.</summary> + /// <remarks> + /// This code is derived from https://stackoverflow.com/a/47521647/262123. It detects Android by calling the + /// <c>getprop</c> system command to check for an Android-specific property. + /// </remarks> + private static bool IsRunningAndroid() + { + using (Process process = new Process()) + { + process.StartInfo.FileName = "getprop"; + process.StartInfo.Arguments = "ro.build.user"; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + try + { + process.Start(); + string output = process.StandardOutput.ReadToEnd(); + return !string.IsNullOrEmpty(output); + } + catch + { + return false; + } + } + } + /// <summary>Detect whether the code is running on Mac.</summary> /// <remarks> /// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the diff --git a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs index 8a3c2b03..40a59d87 100644 --- a/src/SMAPI.Toolkit/Utilities/PathUtilities.cs +++ b/src/SMAPI.Toolkit/Utilities/PathUtilities.cs @@ -6,7 +6,7 @@ using System.Text.RegularExpressions; namespace StardewModdingAPI.Toolkit.Utilities { - /// <summary>Provides utilities for normalising file paths.</summary> + /// <summary>Provides utilities for normalizing file paths.</summary> public static class PathUtilities { /********* @@ -15,14 +15,14 @@ namespace StardewModdingAPI.Toolkit.Utilities /// <summary>The possible directory separator characters in a file path.</summary> private static readonly char[] PossiblePathSeparators = new[] { '/', '\\', Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }.Distinct().ToArray(); - /// <summary>The preferred directory separator chaeacter in an asset key.</summary> + /// <summary>The preferred directory separator character in an asset key.</summary> private static readonly string PreferredPathSeparator = Path.DirectorySeparatorChar.ToString(); /********* ** Public methods *********/ - /// <summary>Get the segments from a path (e.g. <c>/usr/bin/boop</c> => <c>usr</c>, <c>bin</c>, and <c>boop</c>).</summary> + /// <summary>Get the segments from a path (e.g. <c>/usr/bin/example</c> => <c>usr</c>, <c>bin</c>, and <c>example</c>).</summary> /// <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> public static string[] GetSegments(string path, int? limit = null) @@ -32,16 +32,16 @@ namespace StardewModdingAPI.Toolkit.Utilities : path.Split(PathUtilities.PossiblePathSeparators, StringSplitOptions.RemoveEmptyEntries); } - /// <summary>Normalise path separators in a file path.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Normalize path separators in a file path.</summary> + /// <param name="path">The file path to normalize.</param> [Pure] - public static string NormalisePathSeparators(string path) + public static string NormalizePathSeparators(string path) { string[] parts = PathUtilities.GetSegments(path); - string normalised = string.Join(PathUtilities.PreferredPathSeparator, parts); + string normalized = string.Join(PathUtilities.PreferredPathSeparator, parts); if (path.StartsWith(PathUtilities.PreferredPathSeparator)) - normalised = PathUtilities.PreferredPathSeparator + normalised; // keep root slash - return normalised; + normalized = PathUtilities.PreferredPathSeparator + normalized; // keep root slash + return normalized; } /// <summary>Get a directory or file path relative to a given source path.</summary> @@ -57,7 +57,7 @@ namespace StardewModdingAPI.Toolkit.Utilities throw new InvalidOperationException($"Can't get path for '{targetPath}' relative to '{sourceDir}'."); // get relative path - string relative = PathUtilities.NormalisePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); + string relative = PathUtilities.NormalizePathSeparators(Uri.UnescapeDataString(from.MakeRelativeUri(to).ToString())); if (relative == "") relative = "./"; return relative; diff --git a/src/SMAPI.Internal/Platform.cs b/src/SMAPI.Toolkit/Utilities/Platform.cs index 81ca5c1f..f780e812 100644 --- a/src/SMAPI.Internal/Platform.cs +++ b/src/SMAPI.Toolkit/Utilities/Platform.cs @@ -1,8 +1,11 @@ -namespace StardewModdingAPI.Internal +namespace StardewModdingAPI.Toolkit.Utilities { /// <summary>The game's platform version.</summary> - internal enum Platform + public enum Platform { + /// <summary>The Android version of the game.</summary> + Android, + /// <summary>The Linux version of the game.</summary> Linux, diff --git a/src/SMAPI.Web/BackgroundService.cs b/src/SMAPI.Web/BackgroundService.cs new file mode 100644 index 00000000..ee7a60f3 --- /dev/null +++ b/src/SMAPI.Web/BackgroundService.cs @@ -0,0 +1,108 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Hangfire; +using Microsoft.Extensions.Hosting; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; + +namespace StardewModdingAPI.Web +{ + /// <summary>A hosted service which runs background data updates.</summary> + /// <remarks>Task methods need to be static, since otherwise Hangfire will try to serialize the entire instance.</remarks> + internal class BackgroundService : IHostedService, IDisposable + { + /********* + ** Fields + *********/ + /// <summary>The background task server.</summary> + private static BackgroundJobServer JobServer; + + /// <summary>The cache in which to store wiki metadata.</summary> + private static IWikiCacheRepository WikiCache; + + /// <summary>The cache in which to store mod data.</summary> + private static IModCacheRepository ModCache; + + + /********* + ** Public methods + *********/ + /**** + ** Hosted service + ****/ + /// <summary>Construct an instance.</summary> + /// <param name="wikiCache">The cache in which to store wiki metadata.</param> + /// <param name="modCache">The cache in which to store mod data.</param> + public BackgroundService(IWikiCacheRepository wikiCache, IModCacheRepository modCache) + { + BackgroundService.WikiCache = wikiCache; + BackgroundService.ModCache = modCache; + } + + /// <summary>Start the service.</summary> + /// <param name="cancellationToken">Tracks whether the start process has been aborted.</param> + public Task StartAsync(CancellationToken cancellationToken) + { + this.TryInit(); + + // set startup tasks + BackgroundJob.Enqueue(() => BackgroundService.UpdateWikiAsync()); + BackgroundJob.Enqueue(() => BackgroundService.RemoveStaleModsAsync()); + + // set recurring tasks + RecurringJob.AddOrUpdate(() => BackgroundService.UpdateWikiAsync(), "*/10 * * * *"); // every 10 minutes + RecurringJob.AddOrUpdate(() => BackgroundService.RemoveStaleModsAsync(), "0 * * * *"); // hourly + + return Task.CompletedTask; + } + + /// <summary>Triggered when the application host is performing a graceful shutdown.</summary> + /// <param name="cancellationToken">Tracks whether the shutdown process should no longer be graceful.</param> + public async Task StopAsync(CancellationToken cancellationToken) + { + if (BackgroundService.JobServer != null) + await BackgroundService.JobServer.WaitForShutdownAsync(cancellationToken); + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + BackgroundService.JobServer?.Dispose(); + } + + /**** + ** Tasks + ****/ + /// <summary>Update the cached wiki metadata.</summary> + [AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })] + public static async Task UpdateWikiAsync() + { + WikiModList wikiCompatList = await new ModToolkit().GetWikiCompatibilityListAsync(); + BackgroundService.WikiCache.SaveWikiData(wikiCompatList.StableVersion, wikiCompatList.BetaVersion, wikiCompatList.Mods, out _, out _); + } + + /// <summary>Remove mods which haven't been requested in over 48 hours.</summary> + public static Task RemoveStaleModsAsync() + { + BackgroundService.ModCache.RemoveStaleMods(TimeSpan.FromHours(48)); + return Task.CompletedTask; + } + + + /********* + ** Private method + *********/ + /// <summary>Initialize the background service if it's not already initialized.</summary> + /// <exception cref="InvalidOperationException">The background service is already initialized.</exception> + private void TryInit() + { + if (BackgroundService.JobServer != null) + throw new InvalidOperationException("The scheduler service is already started."); + + BackgroundService.JobServer = new BackgroundJobServer(); + } + } +} diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs new file mode 100644 index 00000000..b2eb9a87 --- /dev/null +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Schema; +using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.ConfigModels; +using StardewModdingAPI.Web.ViewModels.JsonValidator; + +namespace StardewModdingAPI.Web.Controllers +{ + /// <summary>Provides a web UI for validating JSON schemas.</summary> + internal class JsonValidatorController : Controller + { + /********* + ** Fields + *********/ + /// <summary>The site config settings.</summary> + private readonly SiteConfig Config; + + /// <summary>The underlying Pastebin client.</summary> + private readonly IPastebinClient Pastebin; + + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; + + /// <summary>The section URL for the schema validator.</summary> + private string SectionUrl => this.Config.JsonValidatorUrl; + + /// <summary>The supported JSON schemas (names indexed by ID).</summary> + private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> + { + ["none"] = "None", + ["manifest"] = "Manifest", + ["content-patcher"] = "Content Patcher" + }; + + /// <summary>The schema ID to use if none was specified.</summary> + private string DefaultSchemaID = "manifest"; + + /// <summary>A token in an error message which indicates that the child errors should be displayed instead.</summary> + private readonly string TransparentToken = "$transparent"; + + + /********* + ** Public methods + *********/ + /*** + ** Constructor + ***/ + /// <summary>Construct an instance.</summary> + /// <param name="siteConfig">The context config settings.</param> + /// <param name="pastebin">The Pastebin API client.</param> + /// <param name="gzipHelper">The underlying text compression helper.</param> + public JsonValidatorController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + { + this.Config = siteConfig.Value; + this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; + } + + /*** + ** Web UI + ***/ + /// <summary>Render the schema validator UI.</summary> + /// <param name="schemaName">The schema name with which to validate the JSON.</param> + /// <param name="id">The paste ID.</param> + [HttpGet] + [Route("json")] + [Route("json/{schemaName}")] + [Route("json/{schemaName}/{id}")] + public async Task<ViewResult> Index(string schemaName = null, string id = null) + { + schemaName = this.NormalizeSchemaName(schemaName); + + var result = new JsonValidatorModel(this.SectionUrl, id, schemaName, this.SchemaFormats); + if (string.IsNullOrWhiteSpace(id)) + return this.View("Index", result); + + // fetch raw JSON + PasteInfo paste = await this.GetAsync(id); + if (string.IsNullOrWhiteSpace(paste.Content)) + return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); + result.SetContent(paste.Content); + + // parse JSON + JToken parsed; + try + { + parsed = JToken.Parse(paste.Content, new JsonLoadSettings + { + DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, + CommentHandling = CommentHandling.Load + }); + } + catch (JsonReaderException ex) + { + return this.View("Index", result.AddErrors(new JsonValidatorErrorModel(ex.LineNumber, ex.Path, ex.Message, ErrorType.None))); + } + + // format JSON + result.SetContent(parsed.ToString(Formatting.Indented)); + + // skip if no schema selected + if (schemaName == "none") + return this.View("Index", result); + + // load schema + JSchema schema; + { + 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)); + } + + // get format doc URL + result.FormatUrl = this.GetExtensionField<string>(schema, "@documentationUrl"); + + // validate JSON + parsed.IsValid(schema, out IList<ValidationError> rawErrors); + var errors = rawErrors + .SelectMany(this.GetErrorModels) + .ToArray(); + return this.View("Index", result.AddErrors(errors)); + } + + /*** + ** JSON + ***/ + /// <summary>Save raw JSON data.</summary> + [HttpPost, AllowLargePosts] + [Route("json")] + public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) + { + if (request == null) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); + + // normalize schema name + string schemaName = this.NormalizeSchemaName(request.SchemaName); + + // get raw log text + string input = request.Content; + if (string.IsNullOrWhiteSpace(input)) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); + + // upload log + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input); + + // handle errors + if (!result.Success) + return this.View("Index", new JsonValidatorModel(this.SectionUrl, result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); + + // redirect to view + UriBuilder uri = new UriBuilder(new Uri(this.SectionUrl)); + uri.Path = $"{uri.Path.TrimEnd('/')}/{schemaName}/{result.ID}"; + return this.Redirect(uri.Uri.ToString()); + } + + + /********* + ** Private methods + *********/ + /// <summary>Fetch raw text from Pastebin.</summary> + /// <param name="id">The Pastebin paste ID.</param> + private async Task<PasteInfo> GetAsync(string id) + { + PasteInfo response = await this.Pastebin.GetAsync(id); + response.Content = this.GzipHelper.DecompressString(response.Content); + return response; + } + + /// <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) + { + schemaName = schemaName?.Trim().ToLower(); + return !string.IsNullOrWhiteSpace(schemaName) + ? schemaName + : this.DefaultSchemaID; + } + + /// <summary>Get the schema file given its unique ID.</summary> + /// <param name="id">The schema ID.</param> + private FileInfo FindSchemaFile(string id) + { + // normalize ID + id = id?.Trim().ToLower(); + if (string.IsNullOrWhiteSpace(id)) + return null; + + // get matching file + DirectoryInfo schemaDir = new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "wwwroot", "schemas")); + foreach (FileInfo file in schemaDir.EnumerateFiles("*.json")) + { + if (file.Name.Equals($"{id}.json")) + return file; + } + + return null; + } + + /// <summary>Get view models representing a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + private IEnumerable<JsonValidatorErrorModel> GetErrorModels(ValidationError error) + { + // skip through transparent errors + if (this.IsTransparentError(error)) + { + foreach (var model in error.ChildErrors.SelectMany(this.GetErrorModels)) + yield return model; + yield break; + } + + // get message + string message = this.GetOverrideError(error); + if (message == null || message == this.TransparentToken) + message = this.FlattenErrorMessage(error); + + // build model + yield return new JsonValidatorErrorModel(error.LineNumber, error.Path, message, error.ErrorType); + } + + /// <summary>Get a flattened, human-readable message for a schema validation error and any child errors.</summary> + /// <param name="error">The error to represent.</param> + /// <param name="indent">The indentation level to apply for inner errors.</param> + private string FlattenErrorMessage(ValidationError error, int indent = 0) + { + // get override + string message = this.GetOverrideError(error); + if (message != null && message != this.TransparentToken) + return message; + + // skip through transparent errors + if (this.IsTransparentError(error)) + error = error.ChildErrors[0]; + + // get friendly representation of main error + message = error.Message; + switch (error.ErrorType) + { + case ErrorType.Const: + message = $"Invalid value. Found '{error.Value}', but expected '{error.Schema.Const}'."; + break; + + case ErrorType.Enum: + message = $"Invalid value. Found '{error.Value}', but expected one of '{string.Join("', '", error.Schema.Enum)}'."; + break; + + case ErrorType.Required: + message = $"Missing required fields: {string.Join(", ", (List<string>)error.Value)}."; + break; + } + + // add inner errors + foreach (ValidationError childError in error.ChildErrors) + message += "\n" + "".PadLeft(indent * 2, ' ') + $"==> {childError.Path}: " + this.FlattenErrorMessage(childError, indent + 1); + return message; + } + + /// <summary>Get whether a validation error should be omitted in favor of its child errors in user-facing error messages.</summary> + /// <param name="error">The error to check.</param> + private bool IsTransparentError(ValidationError error) + { + if (!error.ChildErrors.Any()) + return false; + + string @override = this.GetOverrideError(error); + return + @override == this.TransparentToken + || (error.ErrorType == ErrorType.Then && @override == null); + } + + /// <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) + { + string GetRawOverrideError() + { + // get override errors + IDictionary<string, string> errors = this.GetExtensionField<Dictionary<string, string>>(error.Schema, "@errorMessages"); + if (errors == null) + return null; + errors = new Dictionary<string, string>(errors, StringComparer.InvariantCultureIgnoreCase); + + // match error by type and message + foreach (var pair in errors) + { + if (!pair.Key.Contains(":")) + continue; + + string[] parts = pair.Key.Split(':', 2); + if (parts[0].Equals(error.ErrorType.ToString(), StringComparison.InvariantCultureIgnoreCase) && Regex.IsMatch(error.Message, parts[1])) + return pair.Value?.Trim(); + } + + // match by type + if (errors.TryGetValue(error.ErrorType.ToString(), out string message)) + return message?.Trim(); + + return null; + } + + return GetRawOverrideError() + ?.Replace("@value", this.FormatValue(error.Value)); + } + + /// <summary>Get an extension field from a JSON schema.</summary> + /// <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) + { + if (schema.ExtensionData != null) + { + foreach (var pair in schema.ExtensionData) + { + if (pair.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)) + return pair.Value.ToObject<T>(); + } + } + + return default; + } + + /// <summary>Format a schema value for display.</summary> + /// <param name="value">The value to format.</param> + private string FormatValue(object value) + { + switch (value) + { + case List<string> list: + return string.Join(", ", list); + + default: + return value?.ToString() ?? "null"; + } + } + } +} diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 21e4a56f..f7f19cd8 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,13 +1,12 @@ using System; -using System.IO; -using System.IO.Compression; using System.Linq; -using System.Text; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; +using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing.Models; @@ -27,9 +26,8 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The underlying Pastebin client.</summary> private readonly IPastebinClient Pastebin; - /// <summary>The first bytes in a valid zip file.</summary> - /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks> - private const uint GzipLeadBytes = 0x8b1f; + /// <summary>The underlying text compression helper.</summary> + private readonly IGzipHelper GzipHelper; /********* @@ -41,10 +39,12 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="siteConfig">The context config settings.</param> /// <param name="pastebin">The Pastebin API client.</param> - public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin) + /// <param name="gzipHelper">The underlying text compression helper.</param> + public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) { this.Config = siteConfig.Value; this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; } /*** @@ -60,14 +60,14 @@ namespace StardewModdingAPI.Web.Controllers { // fresh page if (string.IsNullOrWhiteSpace(id)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id)); + return this.View("Index", this.GetModel(id)); // log page PasteInfo paste = await this.GetAsync(id); ParsedLog log = paste.Success ? new LogParser().Parse(paste.Content) : new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error }; - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw)); + return this.View("Index", this.GetModel(id).SetResult(log, raw)); } /*** @@ -81,15 +81,15 @@ namespace StardewModdingAPI.Web.Controllers // get raw log text string input = this.Request.Form["input"].FirstOrDefault(); if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." }); + return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); // upload log - input = this.CompressString(input); - SavePasteResult result = await this.Pastebin.PostAsync(input); + input = this.GzipHelper.CompressString(input); + SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", input); // handle errors if (!result.Success) - return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" }); + return this.View("Index", this.GetModel(result.ID, uploadError: $"Pastebin error: {result.Error ?? "unknown error"}")); // redirect to view UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl)); @@ -106,74 +106,41 @@ namespace StardewModdingAPI.Web.Controllers private async Task<PasteInfo> GetAsync(string id) { PasteInfo response = await this.Pastebin.GetAsync(id); - response.Content = this.DecompressString(response.Content); + response.Content = this.GzipHelper.DecompressString(response.Content); return response; } - /// <summary>Compress a string.</summary> - /// <param name="text">The text to compress.</param> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - private string CompressString(string text) + /// <summary>Build a log parser model.</summary> + /// <param name="pasteID">The paste ID.</param> + /// <param name="uploadError">An error which occurred while uploading the log to Pastebin.</param> + private LogParserModel GetModel(string pasteID, string uploadError = null) { - // get raw bytes - byte[] buffer = Encoding.UTF8.GetBytes(text); - - // compressed - byte[] compressedData; - using (MemoryStream stream = new MemoryStream()) - { - using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) - zipStream.Write(buffer, 0, buffer.Length); - - stream.Position = 0; - compressedData = new byte[stream.Length]; - stream.Read(compressedData, 0, compressedData.Length); - } - - // prefix length - byte[] zipBuffer = new byte[compressedData.Length + 4]; - Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); - Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); - - // return string representation - return Convert.ToBase64String(zipBuffer); + string sectionUrl = this.Config.LogParserUrl; + Platform? platform = this.DetectClientPlatform(); + return new LogParserModel(sectionUrl, pasteID, platform) { UploadError = uploadError }; } - /// <summary>Decompress a string.</summary> - /// <param name="rawText">The compressed text.</param> - /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> - private string DecompressString(string rawText) + /// <summary>Detect the viewer's OS.</summary> + /// <returns>Returns the viewer OS if known, else null.</returns> + private Platform? DetectClientPlatform() { - // get raw bytes - byte[] zipBuffer; - try + string userAgent = this.Request.Headers["User-Agent"]; + switch (userAgent) { - zipBuffer = Convert.FromBase64String(rawText); - } - catch - { - return rawText; // not valid base64, wasn't compressed by the log parser - } + case string ua when ua.Contains("Windows"): + return Platform.Windows; - // skip if not gzip - if (BitConverter.ToUInt16(zipBuffer, 4) != LogParserController.GzipLeadBytes) - return rawText; + case string ua when ua.Contains("Android"): // check for Android before Linux because Android user agents also contain Linux + return Platform.Android; - // decompress - using (MemoryStream memoryStream = new MemoryStream()) - { - // read length prefix - int dataLength = BitConverter.ToInt32(zipBuffer, 0); - memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); - - // read data - byte[] buffer = new byte[dataLength]; - memoryStream.Position = 0; - using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) - gZipStream.Read(buffer, 0, buffer.Length); - - // return original string - return Encoding.UTF8.GetString(buffer); + case string ua when ua.Contains("Linux"): + return Platform.Linux; + + case string ua when ua.Contains("Mac"): + return Platform.Mac; + + default: + return null; } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index 7e6f592c..fe220eb5 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -2,18 +2,19 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; @@ -33,8 +34,11 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The mod repositories which provide mod metadata.</summary> private readonly IDictionary<ModRepositoryKey, IModRepository> Repositories; - /// <summary>The cache in which to store mod metadata.</summary> - private readonly IMemoryCache Cache; + /// <summary>The cache in which to store wiki data.</summary> + private readonly IWikiCacheRepository WikiCache; + + /// <summary>The cache in which to store mod data.</summary> + private readonly IModCacheRepository ModCache; /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> private readonly int SuccessCacheMinutes; @@ -42,9 +46,6 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> private readonly int ErrorCacheMinutes; - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - private readonly string VersionRegex; - /// <summary>The internal mod metadata list.</summary> private readonly ModDatabase ModDatabase; @@ -57,26 +58,29 @@ namespace StardewModdingAPI.Web.Controllers *********/ /// <summary>Construct an instance.</summary> /// <param name="environment">The web hosting environment.</param> - /// <param name="cache">The cache in which to store mod metadata.</param> + /// <param name="wikiCache">The cache in which to store wiki data.</param> + /// <param name="modCache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> /// <param name="chucklefish">The Chucklefish API client.</param> + /// <param name="curseForge">The CurseForge API client.</param> /// <param name="github">The GitHub API client.</param> /// <param name="modDrop">The ModDrop API client.</param> /// <param name="nexus">The Nexus API client.</param> - public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) + public ModsApiController(IHostingEnvironment environment, IWikiCacheRepository wikiCache, IModCacheRepository modCache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, ICurseForgeClient curseForge, IGitHubClient github, IModDropClient modDrop, INexusClient nexus) { - this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json")); + this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "SMAPI.metadata.json")); ModUpdateCheckConfig config = configProvider.Value; this.CompatibilityPageUrl = config.CompatibilityPageUrl; - this.Cache = cache; + this.WikiCache = wikiCache; + this.ModCache = modCache; this.SuccessCacheMinutes = config.SuccessCacheMinutes; this.ErrorCacheMinutes = config.ErrorCacheMinutes; - this.VersionRegex = config.SemanticVersionRegex; this.Repositories = new IModRepository[] { new ChucklefishRepository(chucklefish), + new CurseForgeRepository(curseForge), new GitHubRepository(github), new ModDropRepository(modDrop), new NexusRepository(nexus) @@ -86,21 +90,42 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Fetch version metadata for the given mods.</summary> /// <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) + public async Task<IEnumerable<ModEntryModel>> PostAsync([FromBody] ModSearchModel model, [FromRoute] string version) { if (model?.Mods == null) return new ModEntryModel[0]; + bool legacyMode = SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion) && parsedVersion.IsOlderThan("3.0.0-beta.20191109"); + // fetch wiki data - WikiModEntry[] wikiData = await this.GetWikiDataAsync(); + WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray(); IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase); foreach (ModSearchEntryModel mod in model.Mods) { if (string.IsNullOrWhiteSpace(mod.ID)) continue; - ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata); + ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata || legacyMode, model.ApiVersion); + if (legacyMode) + { + result.Main = result.Metadata.Main; + result.Optional = result.Metadata.Optional; + result.Unofficial = result.Metadata.Unofficial; + result.UnofficialForBeta = result.Metadata.UnofficialForBeta; + result.HasBetaInfo = result.Metadata.BetaCompatibilityStatus != null; + result.SuggestedUpdate = null; + if (!model.IncludeExtendedMetadata) + result.Metadata = null; + } + else 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(); + } + mods[mod.ID] = result; } @@ -116,19 +141,31 @@ namespace StardewModdingAPI.Web.Controllers /// <param name="search">The mod data to match.</param> /// <param name="wikiData">The wiki data.</param> /// <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) + private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiModEntry[] wikiData, bool includeExtendedMetadata, ISemanticVersion apiVersion) { - // crossreference data + // cross-reference data ModDataRecord record = this.ModDatabase.Get(search.ID); WikiModEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(search.ID.Trim(), StringComparer.InvariantCultureIgnoreCase)); - string[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); + UpdateKey[] updateKeys = this.GetUpdateKeys(search.UpdateKeys, record, wikiEntry).ToArray(); // get latest versions ModEntryModel result = new ModEntryModel { ID = search.ID }; IList<string> errors = new List<string>(); - foreach (string updateKey in updateKeys) + ModEntryVersionModel main = null; + ModEntryVersionModel optional = null; + ModEntryVersionModel unofficial = null; + ModEntryVersionModel unofficialForBeta = null; + foreach (UpdateKey updateKey in updateKeys) { + // validate update key + if (!updateKey.LooksValid) + { + errors.Add($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); + continue; + } + // fetch data ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey); if (data.Error != null) @@ -140,76 +177,118 @@ namespace StardewModdingAPI.Web.Controllers // handle main version if (data.Version != null) { - if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version)) + ISemanticVersion version = this.GetMappedVersion(data.Version, wikiEntry?.MapRemoteVersions); + if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'."); continue; } - if (this.IsNewer(version, result.Main?.Version)) - result.Main = new ModEntryVersionModel(version, data.Url); + if (this.IsNewer(version, main?.Version)) + main = new ModEntryVersionModel(version, data.Url); } // handle optional version if (data.PreviewVersion != null) { - if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version)) + ISemanticVersion version = this.GetMappedVersion(data.PreviewVersion, wikiEntry?.MapRemoteVersions); + if (version == null) { errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'."); continue; } - if (this.IsNewer(version, result.Optional?.Version)) - result.Optional = new ModEntryVersionModel(version, data.Url); + if (this.IsNewer(version, optional?.Version)) + optional = new ModEntryVersionModel(version, data.Url); } } // get unofficial version - if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, result.Optional?.Version)) - result.Unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); + if (wikiEntry?.Compatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.Compatibility.UnofficialVersion, optional?.Version)) + unofficial = new ModEntryVersionModel(wikiEntry.Compatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}"); // get unofficial version for beta if (wikiEntry?.HasBetaInfo == true) { - result.HasBetaInfo = true; if (wikiEntry.BetaCompatibility.Status == WikiCompatibilityStatus.Unofficial) { if (wikiEntry.BetaCompatibility.UnofficialVersion != null) { - result.UnofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, result.Optional?.Version)) + unofficialForBeta = (wikiEntry.BetaCompatibility.UnofficialVersion != null && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, main?.Version) && this.IsNewer(wikiEntry.BetaCompatibility.UnofficialVersion, optional?.Version)) ? new ModEntryVersionModel(wikiEntry.BetaCompatibility.UnofficialVersion, $"{this.CompatibilityPageUrl}/#{wikiEntry.Anchor}") : null; } else - result.UnofficialForBeta = result.Unofficial; + unofficialForBeta = unofficial; } } // fallback to preview if latest is invalid - if (result.Main == null && result.Optional != null) + if (main == null && optional != null) { - result.Main = result.Optional; - result.Optional = null; + main = optional; + optional = null; } // special cases if (result.ID == "Pathoschild.SMAPI") { - if (result.Main != null) - result.Main.Url = "https://smapi.io/"; - if (result.Optional != null) - result.Optional.Url = "https://smapi.io/"; + if (main != null) + main.Url = "https://smapi.io/"; + if (optional != null) + optional.Url = "https://smapi.io/"; + } + + // get recommended update (if any) + ISemanticVersion installedVersion = this.GetMappedVersion(search.InstalledVersion?.ToString(), wikiEntry?.MapLocalVersions); + if (apiVersion != null && installedVersion != null) + { + // get newer versions + List<ModEntryVersionModel> updates = new List<ModEntryVersionModel>(); + if (this.IsRecommendedUpdate(installedVersion, main?.Version, useBetaChannel: true)) + updates.Add(main); + if (this.IsRecommendedUpdate(installedVersion, optional?.Version, useBetaChannel: installedVersion.IsPrerelease())) + updates.Add(optional); + if (this.IsRecommendedUpdate(installedVersion, unofficial?.Version, useBetaChannel: search.IsBroken)) + updates.Add(unofficial); + if (this.IsRecommendedUpdate(installedVersion, unofficialForBeta?.Version, useBetaChannel: apiVersion.IsPrerelease())) + updates.Add(unofficialForBeta); + + // get newest version + ModEntryVersionModel newest = null; + foreach (ModEntryVersionModel update in updates) + { + if (newest == null || update.Version.IsNewerThan(newest.Version)) + newest = update; + } + + // set field + result.SuggestedUpdate = newest != null + ? new ModEntryVersionModel(newest.Version, newest.Url) + : null; } // add extended metadata - if (includeExtendedMetadata && (wikiEntry != null || record != null)) - result.Metadata = new ModExtendedMetadataModel(wikiEntry, record); + if (includeExtendedMetadata) + result.Metadata = new ModExtendedMetadataModel(wikiEntry, record, main: main, optional: optional, unofficial: unofficial, unofficialForBeta: unofficialForBeta); // add result result.Errors = errors.ToArray(); return result; } + /// <summary>Get whether a given version should be offered to the user as an update.</summary> + /// <param name="currentVersion">The current semantic version.</param> + /// <param name="newVersion">The target semantic version.</param> + /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered prerelease updates.</param> + private bool IsRecommendedUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) + { + return + newVersion != null + && newVersion.IsNewerThan(currentVersion) + && (useBetaChannel || !newVersion.IsPrerelease()); + } + /// <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> @@ -218,60 +297,38 @@ namespace StardewModdingAPI.Web.Controllers return current != null && (other == null || other.IsOlderThan(current)); } - /// <summary>Get mod data from the wiki compatibility list.</summary> - private async Task<WikiModEntry[]> GetWikiDataAsync() - { - ModToolkit toolkit = new ModToolkit(); - return await this.Cache.GetOrCreateAsync("_wiki", async entry => - { - try - { - WikiModEntry[] entries = (await toolkit.GetWikiCompatibilityListAsync()).Mods; - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes); - return entries; - } - catch - { - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes); - return new WikiModEntry[0]; - } - }); - } - /// <summary>Get the mod info for an update key.</summary> /// <param name="updateKey">The namespaced update key.</param> - private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey) + private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(UpdateKey updateKey) { - // parse update key - UpdateKey parsed = UpdateKey.Parse(updateKey); - if (!parsed.LooksValid) - return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'."); - - // get matching repository - if (!this.Repositories.TryGetValue(parsed.Repository, out IModRepository repository)) - return new ModInfoModel($"There's no mod site with key '{parsed.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); - - // fetch mod info - return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{parsed.ID}".ToLower(), async entry => + // get mod + if (!this.ModCache.TryGetMod(updateKey.Repository, updateKey.ID, out CachedMod mod) || this.ModCache.IsStale(mod.LastUpdated, mod.FetchStatus == RemoteModStatus.TemporaryError ? this.ErrorCacheMinutes : this.SuccessCacheMinutes)) { - ModInfoModel result = await repository.GetModInfoAsync(parsed.ID); + // get site + if (!this.Repositories.TryGetValue(updateKey.Repository, out IModRepository repository)) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"There's no mod site with key '{updateKey.Repository}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}]."); + + // fetch mod + ModInfoModel result = await repository.GetModInfoAsync(updateKey.ID); if (result.Error == null) { if (result.Version == null) - result.Error = $"The update key '{updateKey}' matches a mod with no version number."; - else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) - result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."; + result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with no version number."); + else if (!SemanticVersion.TryParse(result.Version, out _)) + result.SetError(RemoteModStatus.InvalidData, $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'."); } - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); - return result; - }); + + // cache mod + this.ModCache.SaveMod(repository.VendorKey, updateKey.ID, result, out mod); + } + return mod.GetModel(); } /// <summary>Get update keys based on the available mod metadata, while maintaining the precedence order.</summary> /// <param name="specifiedKeys">The specified update keys.</param> /// <param name="record">The mod's entry in SMAPI's internal database.</param> /// <param name="entry">The mod's entry in the wiki list.</param> - public IEnumerable<string> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) + private IEnumerable<UpdateKey> GetUpdateKeys(string[] specifiedKeys, ModDataRecord record, WikiModEntry entry) { IEnumerable<string> GetRaw() { @@ -291,20 +348,70 @@ namespace StardewModdingAPI.Web.Controllers if (entry != null) { if (entry.NexusID.HasValue) - yield return $"Nexus:{entry.NexusID}"; - if (entry.ChucklefishID.HasValue) - yield return $"Chucklefish:{entry.ChucklefishID}"; + yield return $"{ModRepositoryKey.Nexus}:{entry.NexusID}"; if (entry.ModDropID.HasValue) - yield return $"ModDrop:{entry.ModDropID}"; + yield return $"{ModRepositoryKey.ModDrop}:{entry.ModDropID}"; + if (entry.CurseForgeID.HasValue) + yield return $"{ModRepositoryKey.CurseForge}:{entry.CurseForgeID}"; + if (entry.ChucklefishID.HasValue) + yield return $"{ModRepositoryKey.Chucklefish}:{entry.ChucklefishID}"; } } - HashSet<string> seen = new HashSet<string>(StringComparer.InvariantCulture); - foreach (string key in GetRaw()) + HashSet<UpdateKey> seen = new HashSet<UpdateKey>(); + foreach (string rawKey in GetRaw()) { - if (!string.IsNullOrWhiteSpace(key) && seen.Add(key)) + if (string.IsNullOrWhiteSpace(rawKey)) + continue; + + UpdateKey key = UpdateKey.Parse(rawKey); + if (seen.Add(key)) yield return key; } } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to parse.</param> + /// <param name="map">A map of version replacements.</param> + private ISemanticVersion GetMappedVersion(string version, IDictionary<string, string> map) + { + // try mapped version + string rawNewVersion = this.GetRawMappedVersion(version, map); + if (SemanticVersion.TryParse(rawNewVersion, out ISemanticVersion parsedNew)) + return parsedNew; + + // return original version + return SemanticVersion.TryParse(version, out ISemanticVersion parsedOld) + ? parsedOld + : null; + } + + /// <summary>Get a semantic local version for update checks.</summary> + /// <param name="version">The version to map.</param> + /// <param name="map">A map of version replacements.</param> + private string GetRawMappedVersion(string version, IDictionary<string, string> map) + { + if (version == null || map == null || !map.Any()) + return version; + + // match exact raw version + if (map.ContainsKey(version)) + return map[version]; + + // match parsed version + if (SemanticVersion.TryParse(version, out ISemanticVersion parsed)) + { + if (map.ContainsKey(parsed.ToString())) + return map[parsed.ToString()]; + + foreach (var pair in map) + { + if (SemanticVersion.TryParse(pair.Key, out ISemanticVersion target) && parsed.Equals(target) && SemanticVersion.TryParse(pair.Value, out ISemanticVersion newVersion)) + return newVersion.ToString(); + } + } + + return version; + } } } diff --git a/src/SMAPI.Web/Controllers/ModsController.cs b/src/SMAPI.Web/Controllers/ModsController.cs index ca866a8d..b621ded0 100644 --- a/src/SMAPI.Web/Controllers/ModsController.cs +++ b/src/SMAPI.Web/Controllers/ModsController.cs @@ -1,12 +1,8 @@ -using System; using System.Linq; using System.Text.RegularExpressions; -using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; -using StardewModdingAPI.Toolkit; -using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.ViewModels; @@ -19,10 +15,10 @@ namespace StardewModdingAPI.Web.Controllers ** Fields *********/ /// <summary>The cache in which to store mod metadata.</summary> - private readonly IMemoryCache Cache; + private readonly IWikiCacheRepository Cache; - /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> - private readonly int CacheMinutes; + /// <summary>The number of minutes before which wiki data should be considered old.</summary> + private readonly int StaleMinutes; /********* @@ -31,20 +27,20 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>Construct an instance.</summary> /// <param name="cache">The cache in which to store mod metadata.</param> /// <param name="configProvider">The config settings for mod update checks.</param> - public ModsController(IMemoryCache cache, IOptions<ModCompatibilityListConfig> configProvider) + public ModsController(IWikiCacheRepository cache, IOptions<ModCompatibilityListConfig> configProvider) { ModCompatibilityListConfig config = configProvider.Value; this.Cache = cache; - this.CacheMinutes = config.CacheMinutes; + this.StaleMinutes = config.StaleMinutes; } /// <summary>Display information for all mods.</summary> [HttpGet] [Route("mods")] - public async Task<ViewResult> Index() + public ViewResult Index() { - return this.View("Index", await this.FetchDataAsync()); + return this.View("Index", this.FetchData()); } @@ -52,23 +48,23 @@ namespace StardewModdingAPI.Web.Controllers ** Private methods *********/ /// <summary>Asynchronously fetch mod metadata from the wiki.</summary> - public async Task<ModListModel> FetchDataAsync() + public ModListModel FetchData() { - return await this.Cache.GetOrCreateAsync($"{nameof(ModsController)}_mod_list", async entry => - { - WikiModList data = await new ModToolkit().GetWikiCompatibilityListAsync(); - ModListModel model = new ModListModel( - stableVersion: data.StableVersion, - betaVersion: data.BetaVersion, - mods: data - .Mods - .Select(mod => new ModModel(mod)) - .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")) // ignore case, spaces, and special characters when sorting - ); + // fetch cached data + if (!this.Cache.TryGetWikiMetadata(out CachedWikiMetadata metadata)) + return new ModListModel(); - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.CacheMinutes); - return model; - }); + // build model + return new ModListModel( + stableVersion: metadata.StableVersion, + betaVersion: metadata.BetaVersion, + mods: this.Cache + .GetWikiMods() + .Select(mod => new ModModel(mod.GetModel())) + .OrderBy(p => Regex.Replace(p.Name.ToLower(), "[^a-z0-9]", "")), // ignore case, spaces, and special characters when sorting + lastUpdated: metadata.LastUpdated, + isStale: this.Cache.IsStale(metadata.LastUpdated, this.StaleMinutes) + ); } } } diff --git a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs index 5dc0feb6..864aa215 100644 --- a/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs +++ b/src/SMAPI.Web/Framework/AllowLargePostsAttribute.cs @@ -36,7 +36,7 @@ namespace StardewModdingAPI.Web.Framework } /// <summary>Called early in the filter pipeline to confirm request is authorized.</summary> - /// <param name="context">The authorisation filter context.</param> + /// <param name="context">The authorization filter context.</param> public void OnAuthorization(AuthorizationFilterContext context) { IFeatureCollection features = context.HttpContext.Features; diff --git a/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs new file mode 100644 index 00000000..f5354b93 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/BaseCacheRepository.cs @@ -0,0 +1,19 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>The base logic for a cache repository.</summary> + internal abstract class BaseCacheRepository + { + /********* + ** Public methods + *********/ + /// <summary>Whether cached data is stale.</summary> + /// <param name="lastUpdated">The date when the data was updated.</param> + /// <param name="staleMinutes">The age in minutes before data is considered stale.</param> + public bool IsStale(DateTimeOffset lastUpdated, int staleMinutes) + { + return lastUpdated < DateTimeOffset.UtcNow.AddMinutes(-staleMinutes); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs new file mode 100644 index 00000000..5de7e731 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/ICacheRepository.cs @@ -0,0 +1,13 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>Encapsulates logic for accessing data in the cache.</summary> + internal interface ICacheRepository + { + /// <summary>Whether cached data is stale.</summary> + /// <param name="lastUpdated">The date when the data was updated.</param> + /// <param name="staleMinutes">The age in minutes before data is considered stale.</param> + bool IsStale(DateTimeOffset lastUpdated, int staleMinutes); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs new file mode 100644 index 00000000..96eca847 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/CachedMod.cs @@ -0,0 +1,107 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>The model for cached mod data.</summary> + internal class CachedMod + { + /********* + ** Accessors + *********/ + /**** + ** Tracking + ****/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + [BsonIgnoreIfDefault] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>When the data was last requested through the web API.</summary> + public DateTimeOffset LastRequested { get; set; } + + /**** + ** Metadata + ****/ + /// <summary>The mod site on which the mod is found.</summary> + public ModRepositoryKey Site { get; set; } + + /// <summary>The mod's unique ID within the <see cref="Site"/>.</summary> + public string ID { get; set; } + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus FetchStatus { get; set; } + + /// <summary>The error message providing more info for the <see cref="FetchStatus"/>, if applicable.</summary> + public string FetchError { get; set; } + + + /**** + ** Mod info + ****/ + /// <summary>The mod's display name.</summary> + public string Name { get; set; } + + /// <summary>The mod's latest version.</summary> + public string MainVersion { get; set; } + + /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="MainVersion"/>.</summary> + public string PreviewVersion { get; set; } + + /// <summary>The URL for the mod page.</summary> + public string Url { get; set; } + + /// <summary>The license URL, if available.</summary> + public string LicenseUrl { get; set; } + + /// <summary>The license name, if available.</summary> + public string LicenseName { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + public CachedMod() { } + + /// <summary>Construct an instance.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + public CachedMod(ModRepositoryKey site, string id, ModInfoModel mod) + { + // tracking + this.LastUpdated = DateTimeOffset.UtcNow; + this.LastRequested = DateTimeOffset.UtcNow; + + // metadata + this.Site = site; + this.ID = id; + this.FetchStatus = mod.Status; + this.FetchError = mod.Error; + + // mod info + this.Name = mod.Name; + this.MainVersion = mod.Version; + this.PreviewVersion = mod.PreviewVersion; + this.Url = mod.Url; + this.LicenseUrl = mod.LicenseUrl; + this.LicenseName = mod.LicenseName; + } + + /// <summary>Get the API model for the cached data.</summary> + public ModInfoModel GetModel() + { + return new ModInfoModel(name: this.Name, version: this.MainVersion, url: this.Url, previewVersion: this.PreviewVersion) + .SetLicense(this.LicenseUrl, this.LicenseName) + .SetError(this.FetchStatus, this.FetchError); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs new file mode 100644 index 00000000..bcec8b36 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/IModCacheRepository.cs @@ -0,0 +1,31 @@ +using System; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + internal interface IModCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <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(ModRepositoryKey site, string id, out CachedMod 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> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + /// <param name="cachedMod">The stored mod record.</param> + void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod); + + /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> + /// <param name="age">The minimum age for which to remove mods.</param> + void RemoveStaleMods(TimeSpan age); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs new file mode 100644 index 00000000..2e7804a7 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Mods/ModCacheRepository.cs @@ -0,0 +1,104 @@ +using System; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.ModRepositories; + +namespace StardewModdingAPI.Web.Framework.Caching.Mods +{ + /// <summary>Encapsulates logic for accessing the mod data cache.</summary> + internal class ModCacheRepository : BaseCacheRepository, IModCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The collection for cached mod data.</summary> + private readonly IMongoCollection<CachedMod> Mods; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="database">The authenticated MongoDB database.</param> + public ModCacheRepository(IMongoDatabase database) + { + // get collections + this.Mods = database.GetCollection<CachedMod>("mods"); + + // add indexes if needed + this.Mods.Indexes.CreateOne(new CreateIndexModel<CachedMod>(Builders<CachedMod>.IndexKeys.Ascending(p => p.ID).Ascending(p => p.Site))); + } + + /********* + ** Public methods + *********/ + /// <summary>Get the cached mod data.</summary> + /// <param name="site">The mod site to search.</param> + /// <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(ModRepositoryKey site, string id, out CachedMod mod, bool markRequested = true) + { + // get mod + id = this.NormalizeId(id); + mod = this.Mods.Find(entry => entry.ID == id && entry.Site == site).FirstOrDefault(); + if (mod == null) + return false; + + // bump 'last requested' + if (markRequested) + { + mod.LastRequested = DateTimeOffset.UtcNow; + mod = this.SaveMod(mod); + } + + return true; + } + + /// <summary>Save data fetched for a mod.</summary> + /// <param name="site">The mod site on which the mod is found.</param> + /// <param name="id">The mod's unique ID within the <paramref name="site"/>.</param> + /// <param name="mod">The mod data.</param> + /// <param name="cachedMod">The stored mod record.</param> + public void SaveMod(ModRepositoryKey site, string id, ModInfoModel mod, out CachedMod cachedMod) + { + id = this.NormalizeId(id); + + cachedMod = this.SaveMod(new CachedMod(site, id, mod)); + } + + /// <summary>Delete data for mods which haven't been requested within a given time limit.</summary> + /// <param name="age">The minimum age for which to remove mods.</param> + public void RemoveStaleMods(TimeSpan age) + { + DateTimeOffset minDate = DateTimeOffset.UtcNow.Subtract(age); + var result = this.Mods.DeleteMany(p => p.LastRequested < minDate); + } + + + /********* + ** Private methods + *********/ + /// <summary>Save data fetched for a mod.</summary> + /// <param name="mod">The mod data.</param> + public CachedMod SaveMod(CachedMod mod) + { + string id = this.NormalizeId(mod.ID); + + this.Mods.ReplaceOne( + entry => entry.ID == id && entry.Site == mod.Site, + mod, + new UpdateOptions { IsUpsert = true } + ); + + return mod; + } + + /// <summary>Normalize a mod ID for case-insensitive search.</summary> + /// <param name="id">The mod ID.</param> + public string NormalizeId(string id) + { + return id.Trim().ToLower(); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs new file mode 100644 index 00000000..6a103e37 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/UtcDateTimeOffsetSerializer.cs @@ -0,0 +1,40 @@ +using System; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.Serializers; + +namespace StardewModdingAPI.Web.Framework.Caching +{ + /// <summary>Serializes <see cref="DateTimeOffset"/> to a UTC date field instead of the default array.</summary> + public class UtcDateTimeOffsetSerializer : StructSerializerBase<DateTimeOffset> + { + /********* + ** Fields + *********/ + /// <summary>The underlying date serializer.</summary> + private static readonly DateTimeSerializer DateTimeSerializer = new DateTimeSerializer(DateTimeKind.Utc, BsonType.DateTime); + + + /********* + ** Public methods + *********/ + /// <summary>Deserializes a value.</summary> + /// <param name="context">The deserialization context.</param> + /// <param name="args">The deserialization args.</param> + /// <returns>A deserialized value.</returns> + public override DateTimeOffset Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args) + { + DateTime date = UtcDateTimeOffsetSerializer.DateTimeSerializer.Deserialize(context, args); + return new DateTimeOffset(date, TimeSpan.Zero); + } + + /// <summary>Serializes a value.</summary> + /// <param name="context">The serialization context.</param> + /// <param name="args">The serialization args.</param> + /// <param name="value">The object.</param> + public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, DateTimeOffset value) + { + UtcDateTimeOffsetSerializer.DateTimeSerializer.Serialize(context, args, value.UtcDateTime); + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs new file mode 100644 index 00000000..6a560eb4 --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMetadata.cs @@ -0,0 +1,43 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>The model for cached wiki metadata.</summary> + internal class CachedWikiMetadata + { + /********* + ** Accessors + *********/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>The current stable Stardew Valley version.</summary> + public string StableVersion { get; set; } + + /// <summary>The current beta Stardew Valley version.</summary> + public string BetaVersion { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public CachedWikiMetadata() { } + + /// <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 CachedWikiMetadata(string stableVersion, string betaVersion) + { + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; + this.LastUpdated = DateTimeOffset.UtcNow; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs new file mode 100644 index 00000000..8569984a --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/CachedWikiMod.cs @@ -0,0 +1,230 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; +using MongoDB.Bson.Serialization.Options; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>The model for cached wiki mods.</summary> + internal class CachedWikiMod + { + /********* + ** Accessors + *********/ + /**** + ** Tracking + ****/ + /// <summary>The internal MongoDB ID.</summary> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Named per MongoDB conventions.")] + public ObjectId _id { get; set; } + + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /**** + ** Mod info + ****/ + /// <summary>The mod's unique ID. If the mod has alternate/old IDs, they're listed in latest to newest order.</summary> + public string[] ID { get; set; } + + /// <summary>The mod's display name. If the mod has multiple names, the first one is the most canonical name.</summary> + public string[] Name { get; set; } + + /// <summary>The mod's author name. If the author has multiple names, the first one is the most canonical name.</summary> + public string[] Author { get; set; } + + /// <summary>The mod ID on Nexus.</summary> + public int? NexusID { get; set; } + + /// <summary>The mod ID in the Chucklefish mod repo.</summary> + public int? ChucklefishID { get; set; } + + /// <summary>The mod ID in the CurseForge mod repo.</summary> + 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; } + + /// <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; } + + /// <summary>The URL to a non-GitHub source repo.</summary> + public string CustomSourceUrl { get; set; } + + /// <summary>The custom mod page URL (if applicable).</summary> + public string CustomUrl { get; set; } + + /// <summary>The name of the mod which loads this content pack, if applicable.</summary> + public string ContentPackFor { get; set; } + + /// <summary>The human-readable warnings for players about this mod.</summary> + public string[] Warnings { get; set; } + + /// <summary>Extra metadata links (usually for open pull requests).</summary> + public Tuple<Uri, string>[] MetadataLinks { get; set; } + + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> + public string DevNote { get; set; } + + /// <summary>The link anchor for the mod entry in the wiki compatibility list.</summary> + public string Anchor { get; set; } + + /**** + ** Stable compatibility + ****/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus MainStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string MainSummary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string MainBrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public string MainUnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string MainUnofficialUrl { get; set; } + + /**** + ** Beta compatibility + ****/ + /// <summary>The compatibility status.</summary> + public WikiCompatibilityStatus? BetaStatus { get; set; } + + /// <summary>The human-readable summary of the compatibility status or workaround, without HTML formatting.</summary> + public string BetaSummary { get; set; } + + /// <summary>The game or SMAPI version which broke this mod (if applicable).</summary> + public string BetaBrokeIn { get; set; } + + /// <summary>The version of the latest unofficial update, if applicable.</summary> + public string BetaUnofficialVersion { get; set; } + + /// <summary>The URL to the latest unofficial update, if applicable.</summary> + public string BetaUnofficialUrl { get; set; } + + /**** + ** Version maps + ****/ + /// <summary>Maps local versions to a semantic version for update checks.</summary> + [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] + public IDictionary<string, string> MapLocalVersions { get; set; } + + /// <summary>Maps remote versions to a semantic version for update checks.</summary> + [BsonDictionaryOptions(Representation = DictionaryRepresentation.ArrayOfArrays)] + public IDictionary<string, string> MapRemoteVersions { get; set; } + + + /********* + ** Accessors + *********/ + /// <summary>Construct an instance.</summary> + public CachedWikiMod() { } + + /// <summary>Construct an instance.</summary> + /// <param name="mod">The mod data.</param> + public CachedWikiMod(WikiModEntry mod) + { + // tracking + this.LastUpdated = DateTimeOffset.UtcNow; + + // mod info + this.ID = mod.ID; + this.Name = mod.Name; + this.Author = mod.Author; + this.NexusID = mod.NexusID; + this.ChucklefishID = mod.ChucklefishID; + this.CurseForgeID = mod.CurseForgeID; + this.CurseForgeKey = mod.CurseForgeKey; + this.ModDropID = mod.ModDropID; + this.GitHubRepo = mod.GitHubRepo; + this.CustomSourceUrl = mod.CustomSourceUrl; + this.CustomUrl = mod.CustomUrl; + this.ContentPackFor = mod.ContentPackFor; + this.MetadataLinks = mod.MetadataLinks; + this.Warnings = mod.Warnings; + this.DevNote = mod.DevNote; + this.Anchor = mod.Anchor; + + // stable compatibility + this.MainStatus = mod.Compatibility.Status; + this.MainSummary = mod.Compatibility.Summary; + this.MainBrokeIn = mod.Compatibility.BrokeIn; + this.MainUnofficialVersion = mod.Compatibility.UnofficialVersion?.ToString(); + this.MainUnofficialUrl = mod.Compatibility.UnofficialUrl; + + // beta compatibility + this.BetaStatus = mod.BetaCompatibility?.Status; + this.BetaSummary = mod.BetaCompatibility?.Summary; + this.BetaBrokeIn = mod.BetaCompatibility?.BrokeIn; + this.BetaUnofficialVersion = mod.BetaCompatibility?.UnofficialVersion?.ToString(); + this.BetaUnofficialUrl = mod.BetaCompatibility?.UnofficialUrl; + + // version maps + this.MapLocalVersions = mod.MapLocalVersions; + this.MapRemoteVersions = mod.MapRemoteVersions; + } + + /// <summary>Reconstruct the original model.</summary> + public WikiModEntry GetModel() + { + var mod = new WikiModEntry + { + ID = this.ID, + Name = this.Name, + Author = this.Author, + NexusID = this.NexusID, + ChucklefishID = this.ChucklefishID, + CurseForgeID = this.CurseForgeID, + CurseForgeKey = this.CurseForgeKey, + ModDropID = this.ModDropID, + GitHubRepo = this.GitHubRepo, + CustomSourceUrl = this.CustomSourceUrl, + CustomUrl = this.CustomUrl, + ContentPackFor = this.ContentPackFor, + Warnings = this.Warnings, + MetadataLinks = this.MetadataLinks, + DevNote = this.DevNote, + Anchor = this.Anchor, + + // stable compatibility + Compatibility = new WikiCompatibilityInfo + { + Status = this.MainStatus, + Summary = this.MainSummary, + BrokeIn = this.MainBrokeIn, + UnofficialVersion = this.MainUnofficialVersion != null ? new SemanticVersion(this.MainUnofficialVersion) : null, + UnofficialUrl = this.MainUnofficialUrl + }, + + // version maps + MapLocalVersions = this.MapLocalVersions, + MapRemoteVersions = this.MapRemoteVersions + }; + + // beta compatibility + if (this.BetaStatus != null) + { + mod.BetaCompatibility = new WikiCompatibilityInfo + { + Status = this.BetaStatus.Value, + Summary = this.BetaSummary, + BrokeIn = this.BetaBrokeIn, + UnofficialVersion = this.BetaUnofficialVersion != null ? new SemanticVersion(this.BetaUnofficialVersion) : null, + UnofficialUrl = this.BetaUnofficialUrl + }; + } + + return mod; + } + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs new file mode 100644 index 00000000..b54c8a2f --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/IWikiCacheRepository.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> + internal interface IWikiCacheRepository : ICacheRepository + { + /********* + ** Methods + *********/ + /// <summary>Get the cached wiki metadata.</summary> + /// <param name="metadata">The fetched metadata.</param> + bool TryGetWikiMetadata(out CachedWikiMetadata metadata); + + /// <summary>Get the cached wiki mods.</summary> + /// <param name="filter">A filter to apply, if any.</param> + IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, 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> + /// <param name="cachedMetadata">The stored metadata record.</param> + /// <param name="cachedMods">The stored mod records.</param> + void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods); + } +} diff --git a/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs new file mode 100644 index 00000000..1ae9d38f --- /dev/null +++ b/src/SMAPI.Web/Framework/Caching/Wiki/WikiCacheRepository.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using MongoDB.Driver; +using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; + +namespace StardewModdingAPI.Web.Framework.Caching.Wiki +{ + /// <summary>Encapsulates logic for accessing the wiki data cache.</summary> + internal class WikiCacheRepository : BaseCacheRepository, IWikiCacheRepository + { + /********* + ** Fields + *********/ + /// <summary>The collection for wiki metadata.</summary> + private readonly IMongoCollection<CachedWikiMetadata> WikiMetadata; + + /// <summary>The collection for wiki mod data.</summary> + private readonly IMongoCollection<CachedWikiMod> WikiMods; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="database">The authenticated MongoDB database.</param> + public WikiCacheRepository(IMongoDatabase database) + { + // get collections + this.WikiMetadata = database.GetCollection<CachedWikiMetadata>("wiki-metadata"); + this.WikiMods = database.GetCollection<CachedWikiMod>("wiki-mods"); + + // add indexes if needed + this.WikiMods.Indexes.CreateOne(new CreateIndexModel<CachedWikiMod>(Builders<CachedWikiMod>.IndexKeys.Ascending(p => p.ID))); + } + + /// <summary>Get the cached wiki metadata.</summary> + /// <param name="metadata">The fetched metadata.</param> + public bool TryGetWikiMetadata(out CachedWikiMetadata metadata) + { + metadata = this.WikiMetadata.Find("{}").FirstOrDefault(); + return metadata != null; + } + + /// <summary>Get the cached wiki mods.</summary> + /// <param name="filter">A filter to apply, if any.</param> + public IEnumerable<CachedWikiMod> GetWikiMods(Expression<Func<CachedWikiMod, bool>> filter = null) + { + return filter != null + ? this.WikiMods.Find(filter).ToList() + : this.WikiMods.Find("{}").ToList(); + } + + /// <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> + /// <param name="cachedMetadata">The stored metadata record.</param> + /// <param name="cachedMods">The stored mod records.</param> + public void SaveWikiData(string stableVersion, string betaVersion, IEnumerable<WikiModEntry> mods, out CachedWikiMetadata cachedMetadata, out CachedWikiMod[] cachedMods) + { + cachedMetadata = new CachedWikiMetadata(stableVersion, betaVersion); + cachedMods = mods.Select(mod => new CachedWikiMod(mod)).ToArray(); + + this.WikiMods.DeleteMany("{}"); + this.WikiMods.InsertMany(cachedMods); + + this.WikiMetadata.DeleteMany("{}"); + this.WikiMetadata.InsertOne(cachedMetadata); + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs index 2753e33a..939c32c6 100644 --- a/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Chucklefish/ChucklefishClient.cs @@ -45,7 +45,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish .GetAsync(string.Format(this.ModPageUrlFormat, id)) .AsString(); } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound || ex.Status == HttpStatusCode.Forbidden) { return null; } diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs new file mode 100644 index 00000000..140b854e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeClient.cs @@ -0,0 +1,113 @@ +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> + internal class CurseForgeClient : ICurseForgeClient + { + /********* + ** Fields + *********/ + /// <summary>The underlying HTTP client.</summary> + 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); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="userAgent">The user agent for the API client.</param> + /// <param name="apiUrl">The base URL for the CurseForge API.</param> + public CurseForgeClient(string userAgent, string apiUrl) + { + this.Client = new FluentClient(apiUrl).SetUserAgent(userAgent); + } + + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The CurseForge mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + public async Task<CurseForgeMod> GetModAsync(long id) + { + // get raw data + ModModel mod = await this.Client + .GetAsync($"addon/{id}") + .As<ModModel>(); + if (mod == null) + return null; + + // get latest versions + string invalidVersion = null; + ISemanticVersion latest = null; + foreach (ModFileModel file in mod.LatestFiles) + { + // extract version + ISemanticVersion version; + { + string raw = this.GetRawVersion(file); + if (raw == null) + continue; + + if (!SemanticVersion.TryParse(raw, out version)) + { + if (invalidVersion == null) + invalidVersion = raw; + continue; + } + } + + // track latest version + if (latest == null || version.IsNewerThan(latest)) + latest = version; + } + + // get error + string error = null; + if (latest == null && invalidVersion == null) + { + error = mod.LatestFiles.Any() + ? $"CurseForge mod {id} has no downloads which specify the version in a recognised format." + : $"CurseForge mod {id} has no downloads."; + } + + // generate result + return new CurseForgeMod + { + Name = mod.Name, + LatestVersion = latest?.ToString() ?? invalidVersion, + Url = mod.WebsiteUrl, + Error = error + }; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.Client?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <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) + { + Match match = this.VersionInNamePattern.Match(file.DisplayName); + if (!match.Success) + match = this.VersionInNamePattern.Match(file.FileName); + + return match.Success + ? match.Groups[1].Value + : null; + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs new file mode 100644 index 00000000..e5bb8cf1 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/CurseForgeMod.cs @@ -0,0 +1,23 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>Mod metadata from the CurseForge API.</summary> + internal class CurseForgeMod + { + /********* + ** Accessors + *********/ + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The latest file version.</summary> + public string LatestVersion { get; set; } + + /// <summary>The mod's web URL.</summary> + public string Url { get; set; } + + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs new file mode 100644 index 00000000..907b4087 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ICurseForgeClient.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge +{ + /// <summary>An HTTP client for fetching mod metadata from the CurseForge API.</summary> + internal interface ICurseForgeClient : IDisposable + { + /********* + ** Methods + *********/ + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The CurseForge mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + Task<CurseForgeMod> GetModAsync(long id); + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs new file mode 100644 index 00000000..9de74847 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModFileModel.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// <summary>Metadata from the CurseForge API about a mod file.</summary> + public class ModFileModel + { + /// <summary>The file name as downloaded.</summary> + public string FileName { get; set; } + + /// <summary>The file display name.</summary> + public string DisplayName { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs new file mode 100644 index 00000000..48cd185b --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/CurseForge/ResponseModels/ModModel.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.Clients.CurseForge.ResponseModels +{ + /// <summary>An mod from the CurseForge API.</summary> + public class ModModel + { + /// <summary>The mod's unique ID on CurseForge.</summary> + public int ID { get; set; } + + /// <summary>The mod name.</summary> + public string Name { get; set; } + + /// <summary>The web URL for the mod page.</summary> + public string WebsiteUrl { get; set; } + + /// <summary>The available file downloads.</summary> + public ModFileModel[] LatestFiles { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 22950db9..84c20957 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -12,12 +12,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Fields *********/ - /// <summary>The URL for a GitHub API query for the latest stable release, excluding the base URL, where {0} is the organisation and project name.</summary> - private readonly string StableReleaseUrlFormat; - - /// <summary>The URL for a GitHub API query for the latest release (including prerelease), excluding the base URL, where {0} is the organisation and project name.</summary> - private readonly string AnyReleaseUrlFormat; - /// <summary>The underlying HTTP client.</summary> private readonly IClient Client; @@ -27,17 +21,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>Construct an instance.</summary> /// <param name="baseUrl">The base URL for the GitHub API.</param> - /// <param name="stableReleaseUrlFormat">The URL for a GitHub API query for the latest stable release, excluding the <paramref name="baseUrl"/>, where {0} is the organisation and project name.</param> - /// <param name="anyReleaseUrlFormat">The URL for a GitHub API query for the latest release (including prerelease), excluding the <paramref name="baseUrl"/>, where {0} is the organisation and project name.</param> /// <param name="userAgent">The user agent for the API client.</param> /// <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 stableReleaseUrlFormat, string anyReleaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string userAgent, string acceptHeader, string username, string password) { - this.StableReleaseUrlFormat = stableReleaseUrlFormat; - this.AnyReleaseUrlFormat = anyReleaseUrlFormat; - this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) .AddDefault(req => req.WithHeader("Accept", acceptHeader)); @@ -45,25 +34,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub 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) + { + this.AssertKeyFormat(repo); + try + { + return await this.Client + .GetAsync($"repos/{repo}") + .As<GitRepo>(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + } + /// <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> public async Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false) { - this.AssetKeyFormat(repo); + this.AssertKeyFormat(repo); try { if (includePrerelease) { GitRelease[] results = await this.Client - .GetAsync(string.Format(this.AnyReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases?per_page=2") // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) .AsArray<GitRelease>(); return results.FirstOrDefault(p => !p.IsDraft); } return await this.Client - .GetAsync(string.Format(this.StableReleaseUrlFormat, repo)) + .GetAsync($"repos/{repo}/releases/latest") .As<GitRelease>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -85,7 +92,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Assert that a repository key is formatted correctly.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> /// <exception cref="ArgumentException">The repository key is invalid.</exception> - private void AssetKeyFormat(string repo) + private void AssertKeyFormat(string repo) { if (repo == null || !repo.Contains("/") || repo.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != repo.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) throw new ArgumentException($"The value '{repo}' isn't a valid GitHub repository key, must be a username and project name like 'Pathoschild/SMAPI'.", nameof(repo)); diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs new file mode 100644 index 00000000..736efbe6 --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitLicense.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// <summary>The license info for a GitHub project.</summary> + internal class GitLicense + { + /// <summary>The license display name.</summary> + [JsonProperty("name")] + public string Name { get; set; } + + /// <summary>The SPDX ID for the license.</summary> + [JsonProperty("spdx_id")] + public string SpdxId { get; set; } + + /// <summary>The URL for the license info.</summary> + [JsonProperty("url")] + public string Url { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs new file mode 100644 index 00000000..7d80576e --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRepo.cs @@ -0,0 +1,20 @@ +using Newtonsoft.Json; + +namespace StardewModdingAPI.Web.Framework.Clients.GitHub +{ + /// <summary>Basic metadata about a GitHub project.</summary> + internal class GitRepo + { + /// <summary>The full repository name, including the owner.</summary> + [JsonProperty("full_name")] + public string FullName { get; set; } + + /// <summary>The URL to the repository web page, if any.</summary> + [JsonProperty("html_url")] + public string WebUrl { get; set; } + + /// <summary>The code license, if any.</summary> + [JsonProperty("license")] + public GitLicense License { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 9519c26f..a34f03bd 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -9,6 +9,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Methods *********/ + /// <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); + /// <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> diff --git a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs index 291fb353..def79106 100644 --- a/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs +++ b/src/SMAPI.Web/Framework/Clients/ModDrop/ModDropMod.cs @@ -17,8 +17,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.ModDrop /// <summary>The mod's web URL.</summary> public string Url { get; set; } - - /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> - public string Error { get; set; } } } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs new file mode 100644 index 00000000..753d3b4f --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusClient.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using HtmlAgilityPack; +using Pathoschild.FluentNexus.Models; +using Pathoschild.Http.Client; +using StardewModdingAPI.Toolkit; +using FluentNexusClient = Pathoschild.FluentNexus.NexusClient; + +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>An HTTP client for fetching mod metadata from the Nexus website.</summary> + internal class NexusClient : INexusClient + { + /********* + ** Fields + *********/ + /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary> + private readonly string WebModUrlFormat; + + /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary> + public string WebModScrapeUrlFormat { get; set; } + + /// <summary>The underlying HTTP client for the Nexus Mods website.</summary> + private readonly IClient WebClient; + + /// <summary>The underlying HTTP client for the Nexus API.</summary> + private readonly FluentNexusClient ApiClient; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="webUserAgent">The user agent for the Nexus Mods web client.</param> + /// <param name="webBaseUrl">The base URL for the Nexus Mods site.</param> + /// <param name="webModUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="webBaseUrl"/>, where {0} is the mod ID.</param> + /// <param name="webModScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param> + /// <param name="apiAppVersion">The app version to show in API user agents.</param> + /// <param name="apiKey">The Nexus API authentication key.</param> + public NexusClient(string webUserAgent, string webBaseUrl, string webModUrlFormat, string webModScrapeUrlFormat, string apiAppVersion, string apiKey) + { + this.WebModUrlFormat = webModUrlFormat; + this.WebModScrapeUrlFormat = webModScrapeUrlFormat; + this.WebClient = new FluentClient(webBaseUrl).SetUserAgent(webUserAgent); + this.ApiClient = new FluentNexusClient(apiKey, "SMAPI", apiAppVersion); + } + + /// <summary>Get metadata about a mod.</summary> + /// <param name="id">The Nexus mod ID.</param> + /// <returns>Returns the mod info if found, else <c>null</c>.</returns> + public async Task<NexusMod> GetModAsync(uint id) + { + // Fetch from the Nexus website when possible, since it has no rate limits. Mods with + // 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(id); + if (mod?.Status == NexusModStatus.AdultContentForbidden) + mod = await this.GetModFromApiAsync(id); + + return mod; + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public void Dispose() + { + this.WebClient?.Dispose(); + } + + + /********* + ** Private methods + *********/ + /// <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) + { + // fetch HTML + string html; + try + { + html = await this.WebClient + .GetAsync(string.Format(this.WebModScrapeUrlFormat, id)) + .AsString(); + } + catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) + { + return null; + } + + // parse HTML + var doc = new HtmlDocument(); + doc.LoadHtml(html); + + // handle Nexus error message + 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; + switch (errorCode.Trim().ToLower()) + { + case "not found": + return null; + + default: + return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText}).", Status = this.GetWebStatus(errorCode) }; + } + } + + // extract mod info + string url = this.GetModUrl(id); + string name = doc.DocumentNode.SelectSingleNode("//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 file versions + List<string> rawVersions = new List<string>(); + foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) + { + string sectionName = fileSection.Descendants("h2").First().InnerText; + if (sectionName != "Main files" && sectionName != "Optional files") + continue; + + rawVersions.AddRange( + from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) + from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) + select versionStat.InnerText.Trim() + ); + } + + // choose latest file version + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in rawVersions) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = name, + Version = parsedVersion?.ToString() ?? version, + LatestFileVersion = latestFileVersion, + Url = url + }; + } + + /// <summary>Get metadata about a mod from the Nexus API.</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> GetModFromApiAsync(uint id) + { + // fetch mod + Mod mod = await this.ApiClient.Mods.GetMod("stardewvalley", (int)id); + ModFileList files = await this.ApiClient.ModFiles.GetModFiles("stardewvalley", (int)id, FileCategory.Main, FileCategory.Optional); + + // get versions + if (!SemanticVersion.TryParse(mod.Version, out ISemanticVersion mainVersion)) + mainVersion = null; + ISemanticVersion latestFileVersion = null; + foreach (string rawVersion in files.Files.Select(p => p.FileVersion)) + { + if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) + continue; + if (mainVersion != null && !cur.IsNewerThan(mainVersion)) + continue; + if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) + continue; + + latestFileVersion = cur; + } + + // yield info + return new NexusMod + { + Name = mod.Name, + Version = SemanticVersion.TryParse(mod.Version, out ISemanticVersion version) ? version?.ToString() : mod.Version, + LatestFileVersion = latestFileVersion, + Url = this.GetModUrl(id) + }; + } + + /// <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); + builder.Path += string.Format(this.WebModUrlFormat, id); + return builder.Uri.ToString(); + } + + /// <summary>Get the mod status for a web error code.</summary> + /// <param name="errorCode">The Nexus error code.</param> + private NexusModStatus GetWebStatus(string errorCode) + { + switch (errorCode.Trim().ToLower()) + { + case "adult content": + return NexusModStatus.AdultContentForbidden; + + case "hidden mod": + return NexusModStatus.Hidden; + + case "not published": + return NexusModStatus.NotPublished; + + default: + return NexusModStatus.Other; + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs index f4909155..0f1b29d5 100644 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusMod.cs @@ -21,6 +21,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus [JsonProperty("mod_page_uri")] public string Url { get; set; } + /// <summary>The mod's publication status.</summary> + [JsonIgnore] + public NexusModStatus Status { get; set; } = NexusModStatus.Ok; + /// <summary>A user-friendly error which indicates why fetching the mod info failed (if applicable).</summary> [JsonIgnore] public string Error { get; set; } diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs new file mode 100644 index 00000000..9ef314cd --- /dev/null +++ b/src/SMAPI.Web/Framework/Clients/Nexus/NexusModStatus.cs @@ -0,0 +1,21 @@ +namespace StardewModdingAPI.Web.Framework.Clients.Nexus +{ + /// <summary>The status of a Nexus mod.</summary> + internal enum NexusModStatus + { + /// <summary>The mod is published and valid.</summary> + Ok, + + /// <summary>The mod is hidden by the author.</summary> + Hidden, + + /// <summary>The mod hasn't been published yet.</summary> + NotPublished, + + /// <summary>The mod contains adult content which is hidden for anonymous web users.</summary> + AdultContentForbidden, + + /// <summary>The Nexus API returned an unhandled error.</summary> + Other + } +} diff --git a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs b/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs deleted file mode 100644 index e83a6041..00000000 --- a/src/SMAPI.Web/Framework/Clients/Nexus/NexusWebScrapeClient.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net; -using System.Threading.Tasks; -using HtmlAgilityPack; -using Pathoschild.Http.Client; -using StardewModdingAPI.Toolkit; - -namespace StardewModdingAPI.Web.Framework.Clients.Nexus -{ - /// <summary>An HTTP client for fetching mod metadata from the Nexus website.</summary> - internal class NexusWebScrapeClient : INexusClient - { - /********* - ** Fields - *********/ - /// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary> - private readonly string ModUrlFormat; - - /// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary> - public string ModScrapeUrlFormat { get; set; } - - /// <summary>The underlying HTTP client.</summary> - private readonly IClient Client; - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="userAgent">The user agent for the Nexus Mods API client.</param> - /// <param name="baseUrl">The base URL for the Nexus Mods site.</param> - /// <param name="modUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param> - /// <param name="modScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param> - public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat) - { - this.ModUrlFormat = modUrlFormat; - this.ModScrapeUrlFormat = modScrapeUrlFormat; - this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); - } - - /// <summary>Get metadata about a mod.</summary> - /// <param name="id">The Nexus mod ID.</param> - /// <returns>Returns the mod info if found, else <c>null</c>.</returns> - public async Task<NexusMod> GetModAsync(uint id) - { - // fetch HTML - string html; - try - { - html = await this.Client - .GetAsync(string.Format(this.ModScrapeUrlFormat, id)) - .AsString(); - } - catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) - { - return null; - } - - // parse HTML - var doc = new HtmlDocument(); - doc.LoadHtml(html); - - // handle Nexus error message - 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; - switch (errorCode.Trim().ToLower()) - { - case "not found": - return null; - - default: - return new NexusMod { Error = $"Nexus error: {errorCode} ({errorText})." }; - } - } - - // extract mod info - string url = this.GetModUrl(id); - string name = doc.DocumentNode.SelectSingleNode("//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 file versions - List<string> rawVersions = new List<string>(); - foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]")) - { - string sectionName = fileSection.Descendants("h2").First().InnerText; - if (sectionName != "Main files" && sectionName != "Optional files") - continue; - - rawVersions.AddRange( - from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version")) - from versionStat in statBox.Descendants().Where(p => p.HasClass("stat")) - select versionStat.InnerText.Trim() - ); - } - - // choose latest file version - ISemanticVersion latestFileVersion = null; - foreach (string rawVersion in rawVersions) - { - if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur)) - continue; - if (parsedVersion != null && !cur.IsNewerThan(parsedVersion)) - continue; - if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion)) - continue; - - latestFileVersion = cur; - } - - // yield info - return new NexusMod - { - Name = name, - Version = parsedVersion?.ToString() ?? version, - LatestFileVersion = latestFileVersion, - Url = url - }; - } - - /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> - public void Dispose() - { - this.Client?.Dispose(); - } - - - /********* - ** Private methods - *********/ - /// <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.Client.BaseClient.BaseAddress); - builder.Path += string.Format(this.ModUrlFormat, id); - return builder.Uri.ToString(); - } - } -} diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs index 630dfb76..a635abe3 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs @@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin Task<PasteInfo> GetAsync(string id); /// <summary>Save a paste to Pastebin.</summary> + /// <param name="name">The paste name.</param> /// <param name="content">The paste content.</param> - Task<SavePasteResult> PostAsync(string content); + Task<SavePasteResult> PostAsync(string name, string content); } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs index 12c3e83f..2e8a8c68 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; -using System.Text; using System.Threading.Tasks; -using System.Web; using Pathoschild.Http.Client; namespace StardewModdingAPI.Web.Framework.Clients.Pastebin @@ -70,8 +67,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin } /// <summary>Save a paste to Pastebin.</summary> + /// <param name="name">The paste name.</param> /// <param name="content">The paste content.</param> - public async Task<SavePasteResult> PostAsync(string content) + public async Task<SavePasteResult> PostAsync(string name, string content) { try { @@ -82,15 +80,15 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin // post to API string response = await this.Client .PostAsync("api/api_post.php") - .WithBodyContent(this.GetFormUrlEncodedContent(new Dictionary<string, string> + .WithBody(p => p.FormUrlEncoded(new { - ["api_option"] = "paste", - ["api_user_key"] = this.UserKey, - ["api_dev_key"] = this.DevKey, - ["api_paste_private"] = "1", // unlisted - ["api_paste_name"] = $"SMAPI log {DateTime.UtcNow:s}", - ["api_paste_expire_date"] = "N", // never expire - ["api_paste_code"] = content + api_option = "paste", + api_user_key = this.UserKey, + api_dev_key = this.DevKey, + api_paste_private = 1, // unlisted + api_paste_name = name, + api_paste_expire_date = "N", // never expire + api_paste_code = content })) .AsString(); @@ -117,18 +115,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin { this.Client.Dispose(); } - - - /********* - ** Private methods - *********/ - /// <summary>Build an HTTP content body with form-url-encoded content.</summary> - /// <param name="data">The content to encode.</param> - /// <remarks>This bypasses an issue where <see cref="FormUrlEncodedContent"/> restricts the body length to the maximum size of a URL, which isn't applicable here.</remarks> - private HttpContent GetFormUrlEncodedContent(IDictionary<string, string> data) - { - string body = string.Join("&", from arg in data select $"{HttpUtility.UrlEncode(arg.Key)}={HttpUtility.UrlEncode(arg.Value)}"); - return new StringContent(body, Encoding.UTF8, "application/x-www-form-urlencoded"); - } } } diff --git a/src/SMAPI.Web/Framework/Compression/GzipHelper.cs b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs new file mode 100644 index 00000000..cc8f4737 --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/GzipHelper.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; + +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// <summary>Handles GZip compression logic.</summary> + internal class GzipHelper : IGzipHelper + { + /********* + ** Fields + *********/ + /// <summary>The first bytes in a valid zip file.</summary> + /// <remarks>See <a href="https://en.wikipedia.org/wiki/Zip_(file_format)#File_headers"/>.</remarks> + private const uint GzipLeadBytes = 0x8b1f; + + + /********* + ** Public methods + *********/ + /// <summary>Compress a string.</summary> + /// <param name="text">The text to compress.</param> + /// <remarks>Derived from <a href="https://stackoverflow.com/a/17993002/262123"/>.</remarks> + public string CompressString(string text) + { + // get raw bytes + byte[] buffer = Encoding.UTF8.GetBytes(text); + + // compressed + byte[] compressedData; + using (MemoryStream stream = new MemoryStream()) + { + using (GZipStream zipStream = new GZipStream(stream, CompressionLevel.Optimal, leaveOpen: true)) + zipStream.Write(buffer, 0, buffer.Length); + + stream.Position = 0; + compressedData = new byte[stream.Length]; + stream.Read(compressedData, 0, compressedData.Length); + } + + // prefix length + byte[] zipBuffer = new byte[compressedData.Length + 4]; + Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length); + Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4); + + // return string representation + return Convert.ToBase64String(zipBuffer); + } + + /// <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) + { + // get raw bytes + byte[] zipBuffer; + try + { + zipBuffer = Convert.FromBase64String(rawText); + } + catch + { + return rawText; // not valid base64, wasn't compressed by the log parser + } + + // skip if not gzip + if (BitConverter.ToUInt16(zipBuffer, 4) != GzipHelper.GzipLeadBytes) + return rawText; + + // decompress + using (MemoryStream memoryStream = new MemoryStream()) + { + // read length prefix + int dataLength = BitConverter.ToInt32(zipBuffer, 0); + memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4); + + // read data + byte[] buffer = new byte[dataLength]; + memoryStream.Position = 0; + using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) + gZipStream.Read(buffer, 0, buffer.Length); + + // return original string + return Encoding.UTF8.GetString(buffer); + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs new file mode 100644 index 00000000..a000865e --- /dev/null +++ b/src/SMAPI.Web/Framework/Compression/IGzipHelper.cs @@ -0,0 +1,17 @@ +namespace StardewModdingAPI.Web.Framework.Compression +{ + /// <summary>Handles GZip compression logic.</summary> + internal interface IGzipHelper + { + /********* + ** Methods + *********/ + /// <summary>Compress a string.</summary> + /// <param name="text">The text to compress.</param> + string CompressString(string text); + + /// <summary>Decompress a string.</summary> + /// <param name="rawText">The compressed text.</param> + string DecompressString(string rawText); + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index c27cadab..121690c5 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -24,17 +24,18 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /**** + ** CurseForge + ****/ + /// <summary>The base URL for the CurseForge API.</summary> + public string CurseForgeBaseUrl { get; set; } + + + /**** ** GitHub ****/ /// <summary>The base URL for the GitHub API.</summary> public string GitHubBaseUrl { get; set; } - /// <summary>The URL for a GitHub API query for the latest stable release, excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> - public string GitHubStableReleaseUrlFormat { get; set; } - - /// <summary>The URL for a GitHub API query for the latest release (including prerelease), excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> - public string GitHubAnyReleaseUrlFormat { get; set; } - /// <summary>The Accept header value expected by the GitHub API.</summary> public string GitHubAcceptHeader { get; set; } @@ -65,6 +66,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <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; } + /// <summary>The Nexus API authentication key.</summary> + public string NexusApiKey { get; set; } + /**** ** Pastebin ****/ diff --git a/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs new file mode 100644 index 00000000..de871c9a --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/BackgroundServicesConfig.cs @@ -0,0 +1,12 @@ +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for background services.</summary> + internal class BackgroundServicesConfig + { + /********* + ** Accessors + *********/ + /// <summary>Whether to enable background update services.</summary> + public bool Enabled { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs index d9ac9f02..24b540cd 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModCompatibilityListConfig.cs @@ -1,12 +1,12 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels { - /// <summary>The config settings for mod compatibility list.</summary> + /// <summary>The config settings for the mod compatibility list.</summary> internal class ModCompatibilityListConfig { /********* ** Accessors *********/ - /// <summary>The number of minutes data from the wiki should be cached before refetching it.</summary> - public int CacheMinutes { get; set; } + /// <summary>The number of minutes before which wiki data should be considered old.</summary> + public int StaleMinutes { get; set; } } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index bde566c0..ab935bb3 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -12,10 +12,6 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The number of minutes failed update checks should be cached before refetching them.</summary> public int ErrorCacheMinutes { get; set; } - /// <summary>A regex which matches SMAPI-style semantic version.</summary> - /// <remarks>Derived from SMAPI's SemanticVersion implementation.</remarks> - public string SemanticVersionRegex { get; set; } - /// <summary>The web URL for the wiki compatibility list.</summary> public string CompatibilityPageUrl { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs new file mode 100644 index 00000000..3c508300 --- /dev/null +++ b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs @@ -0,0 +1,38 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.ConfigModels +{ + /// <summary>The config settings for mod compatibility list.</summary> + internal class MongoDbConfig + { + /********* + ** Accessors + *********/ + /// <summary>The MongoDB hostname.</summary> + public string Host { get; set; } + + /// <summary>The MongoDB username (if any).</summary> + public string Username { get; set; } + + /// <summary>The MongoDB password (if any).</summary> + public string Password { get; set; } + + /// <summary>The database name.</summary> + public string Database { get; set; } + + + /********* + ** Public method + *********/ + /// <summary>Get the MongoDB connection string.</summary> + public string GetConnectionString() + { + bool isLocal = this.Host == "localhost"; + bool hasLogin = !string.IsNullOrWhiteSpace(this.Username) && !string.IsNullOrWhiteSpace(this.Password); + + return $"mongodb{(isLocal ? "" : "+srv")}://" + + (hasLogin ? $"{Uri.EscapeDataString(this.Username)}:{Uri.EscapeDataString(this.Password)}@" : "") + + $"{this.Host}/{this.Database}?retryWrites=true&w=majority"; + } + } +} diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index d89a4260..bc6e868a 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -12,6 +12,9 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The root URL for the log parser.</summary> public string LogParserUrl { get; set; } + /// <summary>The root URL for the JSON validator.</summary> + public string JsonValidatorUrl { get; set; } + /// <summary>The root URL for the mod list.</summary> public string ModListUrl { get; set; } diff --git a/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs new file mode 100644 index 00000000..385c0c91 --- /dev/null +++ b/src/SMAPI.Web/Framework/JobDashboardAuthorizationFilter.cs @@ -0,0 +1,34 @@ +using Hangfire.Dashboard; + +namespace StardewModdingAPI.Web.Framework +{ + /// <summary>Authorizes requests to access the Hangfire job dashboard.</summary> + internal class JobDashboardAuthorizationFilter : IDashboardAuthorizationFilter + { + /********* + ** Fields + *********/ + /// <summary>An authorization filter that allows local requests.</summary> + private static readonly LocalRequestsOnlyAuthorizationFilter LocalRequestsOnlyFilter = new LocalRequestsOnlyAuthorizationFilter(); + + + /********* + ** Public methods + *********/ + /// <summary>Authorize a request.</summary> + /// <param name="context">The dashboard context.</param> + public bool Authorize(DashboardContext context) + { + return + context.IsReadOnly // always allow readonly access + || JobDashboardAuthorizationFilter.IsLocalRequest(context); // else allow access from localhost + } + + /// <summary>Get whether a request originated from a user on the server machine.</summary> + /// <param name="context">The dashboard context.</param> + public static bool IsLocalRequest(DashboardContext context) + { + return JobDashboardAuthorizationFilter.LocalRequestsOnlyFilter.Authorize(context); + } + } +} diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs index 595e6b49..66a3687f 100644 --- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs +++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs @@ -221,7 +221,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing } } - // finalise log + // finalize log gameMod.Version = log.GameVersion; log.Mods = new[] { gameMod, smapiMod }.Concat(mods.Values.OrderBy(p => p.Name)).ToArray(); return log; diff --git a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs index 94256005..f9f9f47d 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/BaseRepository.cs @@ -34,9 +34,9 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories this.VendorKey = vendorKey; } - /// <summary>Normalise a version string.</summary> - /// <param name="version">The version to normalise.</param> - protected string NormaliseVersion(string version) + /// <summary>Normalize a version string.</summary> + /// <param name="version">The version to normalize.</param> + protected string NormalizeVersion(string version) { if (string.IsNullOrWhiteSpace(version)) return null; diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index 87e29a2f..0945735a 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -32,21 +32,19 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!uint.TryParse(id, out uint realID)) - return new ModInfoModel($"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Chucklefish mod ID, must be an integer ID."); // fetch info try { var mod = await this.Client.GetModAsync(realID); - if (mod == null) - return new ModInfoModel("Found no mod with this ID."); - - // create model - return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url); + return mod != null + ? new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), url: mod.Url) + : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Chucklefish mod with this ID."); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs new file mode 100644 index 00000000..93ddc1eb --- /dev/null +++ b/src/SMAPI.Web/Framework/ModRepositories/CurseForgeRepository.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; + +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>An HTTP client for fetching mod metadata from CurseForge.</summary> + internal class CurseForgeRepository : RepositoryBase + { + /********* + ** Fields + *********/ + /// <summary>The underlying CurseForge API client.</summary> + private readonly ICurseForgeClient Client; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="client">The underlying CurseForge API client.</param> + public CurseForgeRepository(ICurseForgeClient client) + : base(ModRepositoryKey.CurseForge) + { + this.Client = client; + } + + /// <summary>Get metadata about a mod in the repository.</summary> + /// <param name="id">The mod ID in this repository.</param> + public override async Task<ModInfoModel> GetModInfoAsync(string id) + { + // validate ID format + if (!uint.TryParse(id, out uint curseID)) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid CurseForge mod ID, must be an integer ID."); + + // fetch info + try + { + CurseForgeMod mod = await this.Client.GetModAsync(curseID); + if (mod == null) + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no CurseForge mod with this ID."); + if (mod.Error != null) + { + RemoteModStatus remoteStatus = RemoteModStatus.InvalidData; + return new ModInfoModel().SetError(remoteStatus, mod.Error); + } + + return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.LatestVersion), url: mod.Url); + } + catch (Exception ex) + { + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); + } + } + + /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> + public override void Dispose() + { + this.Client.Dispose(); + } + } +} diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 14f44dc0..c62cb73f 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -30,36 +30,46 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <param name="id">The mod ID in this repository.</param> public override async Task<ModInfoModel> GetModInfoAsync(string id) { + ModInfoModel result = new ModInfoModel().SetBasicInfo(id, $"https://github.com/{id}/releases"); + // validate ID format if (!id.Contains("/") || id.IndexOf("/", StringComparison.InvariantCultureIgnoreCase) != id.LastIndexOf("/", StringComparison.InvariantCultureIgnoreCase)) - return new ModInfoModel($"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); + return result.SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid GitHub mod ID, must be a username and project name like 'Pathoschild/LookupAnything'."); // fetch info try { + // fetch repo info + GitRepo repository = await this.Client.GetRepositoryAsync(id); + if (repository == null) + return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub repository for this ID."); + result + .SetBasicInfo(repository.FullName, $"{repository.WebUrl}/releases") + .SetLicense(url: repository.License?.Url, name: repository.License?.SpdxId ?? repository.License?.Name); + // get latest release (whether preview or stable) GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); if (latest == null) - return new ModInfoModel("Found no mod with this ID."); + return result.SetError(RemoteModStatus.DoesNotExist, "Found no GitHub release for this ID."); // split stable/prerelease if applicable GitRelease preview = null; if (latest.IsPrerelease) { - GitRelease result = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); - if (result != null) + GitRelease release = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); + if (release != null) { preview = latest; - latest = result; + latest = release; } } // return data - return new ModInfoModel(name: id, version: this.NormaliseVersion(latest.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases"); + return result.SetVersions(version: this.NormalizeVersion(latest.Tag), previewVersion: this.NormalizeVersion(preview?.Tag)); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return result.SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs index 1994f515..62142668 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ModDropRepository.cs @@ -32,21 +32,19 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!long.TryParse(id, out long modDropID)) - return new ModInfoModel($"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid ModDrop mod ID, must be an integer ID."); // fetch info try { ModDropMod mod = await this.Client.GetModAsync(modDropID); - if (mod == null) - return new ModInfoModel("Found no mod with this ID."); - if (mod.Error != null) - return new ModInfoModel(mod.Error); - return new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url); + return mod != null + ? new ModInfoModel(name: mod.Name, version: mod.LatestDefaultVersion?.ToString(), previewVersion: mod.LatestOptionalVersion?.ToString(), url: mod.Url) + : new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no ModDrop mod with this ID."); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs index 18252298..46b98860 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ModInfoModel.cs @@ -9,15 +9,24 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories /// <summary>The mod name.</summary> public string Name { get; set; } - /// <summary>The mod's latest release number.</summary> + /// <summary>The mod's latest version.</summary> public string Version { get; set; } - /// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary> + /// <summary>The mod's latest optional or prerelease version, if newer than <see cref="Version"/>.</summary> public string PreviewVersion { get; set; } /// <summary>The mod's web URL.</summary> public string Url { get; set; } + /// <summary>The license URL, if available.</summary> + public string LicenseUrl { get; set; } + + /// <summary>The license name, if available.</summary> + public string LicenseName { get; set; } + + /// <summary>The mod availability status on the remote site.</summary> + public RemoteModStatus Status { get; set; } = RemoteModStatus.Ok; + /// <summary>The error message indicating why the mod is invalid (if applicable).</summary> public string Error { get; set; } @@ -26,31 +35,62 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories ** Public methods *********/ /// <summary>Construct an empty instance.</summary> - public ModInfoModel() - { - // needed for JSON deserialising - } + public ModInfoModel() { } /// <summary>Construct an instance.</summary> /// <param name="name">The mod name.</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> - /// <param name="error">The error message indicating why the mod is invalid (if applicable).</param> - public ModInfoModel(string name, string version, string url, string previewVersion = null, string error = null) + public ModInfoModel(string name, string version, string url, string previewVersion = null) + { + this + .SetBasicInfo(name, url) + .SetVersions(version, previewVersion); + } + + /// <summary>Set the basic mod info.</summary> + /// <param name="name">The mod name.</param> + /// <param name="url">The mod's web URL.</param> + public ModInfoModel SetBasicInfo(string name, string url) { this.Name = name; + this.Url = url; + + return this; + } + + /// <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(string version, string previewVersion = null) + { this.Version = version; this.PreviewVersion = previewVersion; - this.Url = url; - this.Error = error; + + return this; } - /// <summary>Construct an instance.</summary> - /// <param name="error">The error message indicating why the mod is invalid.</param> - public ModInfoModel(string error) + /// <summary>Set the license info, if available.</summary> + /// <param name="url">The license URL.</param> + /// <param name="name">The license name.</param> + public ModInfoModel SetLicense(string url, string name) { + this.LicenseUrl = url; + this.LicenseName = name; + + return this; + } + + /// <summary>Set a mod error.</summary> + /// <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> + public ModInfoModel SetError(RemoteModStatus status, string error) + { + this.Status = status; this.Error = error; + + return this; } } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index 4c5fe9bf..9551258c 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -32,21 +32,27 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories { // validate ID format if (!uint.TryParse(id, out uint nexusID)) - return new ModInfoModel($"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, $"The value '{id}' isn't a valid Nexus mod ID, must be an integer ID."); // fetch info try { NexusMod mod = await this.Client.GetModAsync(nexusID); if (mod == null) - return new ModInfoModel("Found no mod with this ID."); + return new ModInfoModel().SetError(RemoteModStatus.DoesNotExist, "Found no Nexus mod with this ID."); if (mod.Error != null) - return new ModInfoModel(mod.Error); - return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); + { + RemoteModStatus remoteStatus = mod.Status == NexusModStatus.Hidden || mod.Status == NexusModStatus.NotPublished + ? RemoteModStatus.DoesNotExist + : RemoteModStatus.TemporaryError; + return new ModInfoModel().SetError(remoteStatus, mod.Error); + } + + return new ModInfoModel(name: mod.Name, version: this.NormalizeVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url); } catch (Exception ex) { - return new ModInfoModel(ex.ToString()); + return new ModInfoModel().SetError(RemoteModStatus.TemporaryError, ex.ToString()); } } diff --git a/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs new file mode 100644 index 00000000..02876556 --- /dev/null +++ b/src/SMAPI.Web/Framework/ModRepositories/RemoteModStatus.cs @@ -0,0 +1,18 @@ +namespace StardewModdingAPI.Web.Framework.ModRepositories +{ + /// <summary>The mod availability status on a remote site.</summary> + internal enum RemoteModStatus + { + /// <summary>The mod is valid.</summary> + Ok, + + /// <summary>The mod data was fetched, but the data is not valid (e.g. version isn't semantic).</summary> + InvalidData, + + /// <summary>The mod does not exist.</summary> + DoesNotExist, + + /// <summary>The mod was temporarily unavailable (e.g. the site could not be reached or an unknown error occurred).</summary> + TemporaryError + } +} diff --git a/src/SMAPI.Web/Properties/AssemblyInfo.cs b/src/SMAPI.Web/Properties/AssemblyInfo.cs deleted file mode 100644 index 31e6fc30..00000000 --- a/src/SMAPI.Web/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,4 +0,0 @@ -using System.Reflection; - -[assembly: AssemblyTitle("SMAPI.Web")] -[assembly: AssemblyDescription("")] diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj index d47361bd..8a7ca741 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/SMAPI.Web.csproj @@ -1,8 +1,9 @@ <Project Sdk="Microsoft.NET.Sdk.Web"> <PropertyGroup> + <AssemblyName>SMAPI.Web</AssemblyName> + <RootNamespace>StardewModdingAPI.Web</RootNamespace> <TargetFramework>netcoreapp2.0</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <LangVersion>latest</LangVersion> </PropertyGroup> @@ -11,25 +12,30 @@ </ItemGroup> <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.8.9" /> - <PackageReference Include="Markdig" Version="0.15.4" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.1.4" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" /> - <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" /> - <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" /> - <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" /> - <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" /> + <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> + <PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> + <PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> + <PackageReference Include="Humanizer.Core" Version="2.7.9" /> + <PackageReference Include="Markdig" Version="0.18.0" /> + <PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> + <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> + <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="3.0.1" /> + <PackageReference Include="MongoDB.Driver" Version="2.9.3" /> + <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> + <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> + <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" /> </ItemGroup> <ItemGroup> <DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" /> </ItemGroup> <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> + <None Include="..\..\docs\technical\web.md" Link="web.md" /> + </ItemGroup> + <ItemGroup> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> </ItemGroup> <ItemGroup> <Content Update="Views\Index\Privacy.cshtml"> @@ -38,9 +44,11 @@ <Content Update="Views\Mods\Index.cshtml"> <Pack>$(IncludeRazorContentInPack)</Pack> </Content> - <Content Update="wwwroot\StardewModdingAPI.metadata.json"> + <Content Update="wwwroot\SMAPI.metadata.json"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> </ItemGroup> + <Import Project="..\..\build\common.targets" /> + </Project> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index a2e47482..8110b696 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -1,19 +1,27 @@ using System.Collections.Generic; +using Hangfire; +using Hangfire.Mongo; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Rewrite; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Web.Framework; +using StardewModdingAPI.Web.Framework.Caching; +using StardewModdingAPI.Web.Framework.Caching.Mods; +using StardewModdingAPI.Web.Framework.Caching.Wiki; using StardewModdingAPI.Web.Framework.Clients.Chucklefish; +using StardewModdingAPI.Web.Framework.Clients.CurseForge; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.Framework.Clients.ModDrop; using StardewModdingAPI.Web.Framework.Clients.Nexus; using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.RewriteRules; @@ -48,12 +56,15 @@ namespace StardewModdingAPI.Web /// <param name="services">The service injection container.</param> public void ConfigureServices(IServiceCollection services) { - // init configuration + // init basic services services + .Configure<BackgroundServicesConfig>(this.Configuration.GetSection("BackgroundServices")) .Configure<ModCompatibilityListConfig>(this.Configuration.GetSection("ModCompatibilityList")) .Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck")) + .Configure<MongoDbConfig>(this.Configuration.GetSection("MongoDB")) .Configure<SiteConfig>(this.Configuration.GetSection("Site")) .Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint))) + .AddLogging() .AddMemoryCache() .AddMvc() .ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider())) @@ -65,6 +76,38 @@ namespace StardewModdingAPI.Web options.SerializerSettings.Formatting = Formatting.Indented; options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; }); + MongoDbConfig mongoConfig = this.Configuration.GetSection("MongoDB").Get<MongoDbConfig>(); + + // init background service + { + BackgroundServicesConfig config = this.Configuration.GetSection("BackgroundServices").Get<BackgroundServicesConfig>(); + if (config.Enabled) + services.AddHostedService<BackgroundService>(); + } + + // init MongoDB + services.AddSingleton<IMongoDatabase>(serv => + { + BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); + return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database); + }); + services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); + services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); + + // init Hangfire + services + .AddHangfire(config => + { + config + .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), + CheckConnection = false // error on startup takes down entire process + }); + }); // init API clients { @@ -77,11 +120,13 @@ namespace StardewModdingAPI.Web baseUrl: api.ChucklefishBaseUrl, modPageUrlFormat: api.ChucklefishModPageUrlFormat )); + services.AddSingleton<ICurseForgeClient>(new CurseForgeClient( + userAgent: userAgent, + apiUrl: api.CurseForgeBaseUrl + )); services.AddSingleton<IGitHubClient>(new GitHubClient( baseUrl: api.GitHubBaseUrl, - stableReleaseUrlFormat: api.GitHubStableReleaseUrlFormat, - anyReleaseUrlFormat: api.GitHubAnyReleaseUrlFormat, userAgent: userAgent, acceptHeader: api.GitHubAcceptHeader, username: api.GitHubUsername, @@ -94,11 +139,13 @@ namespace StardewModdingAPI.Web modUrlFormat: api.ModDropModPageUrl )); - services.AddSingleton<INexusClient>(new NexusWebScrapeClient( - userAgent: userAgent, - baseUrl: api.NexusBaseUrl, - modUrlFormat: api.NexusModUrlFormat, - modScrapeUrlFormat: api.NexusModScrapeUrlFormat + services.AddSingleton<INexusClient>(new NexusClient( + webUserAgent: userAgent, + webBaseUrl: api.NexusBaseUrl, + webModUrlFormat: api.NexusModUrlFormat, + webModScrapeUrlFormat: api.NexusModScrapeUrlFormat, + apiAppVersion: version, + apiKey: api.NexusApiKey )); services.AddSingleton<IPastebinClient>(new PastebinClient( @@ -108,20 +155,19 @@ namespace StardewModdingAPI.Web devKey: api.PastebinDevKey )); } + + // init helpers + services.AddSingleton<IGzipHelper>(new GzipHelper()); } /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> /// <param name="app">The application builder.</param> /// <param name="env">The hosting environment.</param> - /// <param name="loggerFactory">The logger factory.</param> - public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) + public void Configure(IApplicationBuilder app, IHostingEnvironment env) { - loggerFactory.AddConsole(this.Configuration.GetSection("Logging")); - loggerFactory.AddDebug(); - + // basic config if (env.IsDevelopment()) app.UseDeveloperExceptionPage(); - app .UseCors(policy => policy .AllowAnyHeader() @@ -132,6 +178,13 @@ namespace StardewModdingAPI.Web .UseRewriter(this.GetRedirectRules()) .UseStaticFiles() // wwwroot folder .UseMvc(); + + // enable Hangfire dashboard + app.UseHangfireDashboard("/tasks", new DashboardOptions + { + IsReadOnlyFunc = context => !JobDashboardAuthorizationFilter.IsLocalRequest(context), + Authorization = new[] { new JobDashboardAuthorizationFilter() } + }); } @@ -155,14 +208,15 @@ namespace StardewModdingAPI.Web redirects.Add(new ConditionalRewriteSubdomainRule( shouldRewrite: req => req.Host.Host != "localhost" - && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) + && (req.Host.Host.StartsWith("api.") || req.Host.Host.StartsWith("json.") || req.Host.Host.StartsWith("log.") || req.Host.Host.StartsWith("mods.")) && !req.Path.StartsWithSegments("/content") && !req.Path.StartsWithSegments("/favicon.ico") )); // shortcut redirects redirects.Add(new RedirectToUrlRule(@"^/3\.0\.?$", "https://stardewvalleywiki.com/Modding:Migrate_to_SMAPI_3.0")); - redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1")); + redirects.Add(new RedirectToUrlRule(@"^/(?:buildmsg|package)(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/mod-package.md#$1")); // buildmsg deprecated, remove when SDV 1.4 is released + redirects.Add(new RedirectToUrlRule(@"^/community\.?$", "https://stardewvalleywiki.com/Modding:Community")); redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://mods.smapi.io")); redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index")); redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI")); diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs new file mode 100644 index 00000000..62b95501 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorErrorModel.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json.Schema; + +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for a JSON validator error.</summary> + public class JsonValidatorErrorModel + { + /********* + ** Accessors + *********/ + /// <summary>The line number on which the error occurred.</summary> + public int Line { get; set; } + + /// <summary>The field path in the JSON file where the error occurred.</summary> + public string Path { get; set; } + + /// <summary>A human-readable description of the error.</summary> + public string Message { get; set; } + + /// <summary>The schema error type.</summary> + public ErrorType SchemaErrorType { get; set; } + + + /********* + ** 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) + { + this.Line = line; + this.Path = path; + this.Message = message; + this.SchemaErrorType = schemaErrorType; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs new file mode 100644 index 00000000..2d13bf23 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Linq; + +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for the JSON validator page.</summary> + public class JsonValidatorModel + { + /********* + ** Accessors + *********/ + /// <summary>The root URL for the log parser controller.</summary> + public string SectionUrl { get; set; } + + /// <summary>The paste ID.</summary> + public string PasteID { get; set; } + + /// <summary>The schema name with which the JSON was validated.</summary> + public string SchemaName { get; set; } + + /// <summary>The supported JSON schemas (names indexed by ID).</summary> + public readonly IDictionary<string, string> SchemaFormats; + + /// <summary>The validated content.</summary> + public string Content { get; set; } + + /// <summary>The schema validation errors, if any.</summary> + public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; + + /// <summary>An error which occurred while uploading the JSON to Pastebin.</summary> + public string UploadError { get; set; } + + /// <summary>An error which occurred while parsing the JSON.</summary> + public string ParseError { get; set; } + + /// <summary>A web URL to the user-facing format documentation.</summary> + public string FormatUrl { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public JsonValidatorModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="sectionUrl">The root URL for the log parser controller.</param> + /// <param name="pasteID">The paste 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> + public JsonValidatorModel(string sectionUrl, string pasteID, string schemaName, IDictionary<string, string> schemaFormats) + { + this.SectionUrl = sectionUrl; + this.PasteID = pasteID; + this.SchemaName = schemaName; + this.SchemaFormats = schemaFormats; + } + + /// <summary>Set the validated content.</summary> + /// <param name="content">The validated content.</param> + public JsonValidatorModel SetContent(string content) + { + this.Content = content; + + return this; + } + + /// <summary>Set the error which occurred while uploading the log to Pastebin.</summary> + /// <param name="error">The error message.</param> + public JsonValidatorModel SetUploadError(string error) + { + this.UploadError = error; + + return this; + } + + /// <summary>Set the error which occurred while parsing the JSON.</summary> + /// <param name="error">The error message.</param> + public JsonValidatorModel SetParseError(string error) + { + this.ParseError = error; + + return this; + } + + /// <summary>Add validation errors to the response.</summary> + /// <param name="errors">The schema validation errors.</param> + public JsonValidatorModel AddErrors(params JsonValidatorErrorModel[] errors) + { + this.Errors = this.Errors.Concat(errors).ToArray(); + + return this; + } + } +} diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs new file mode 100644 index 00000000..c8e851bf --- /dev/null +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorRequestModel.cs @@ -0,0 +1,15 @@ +namespace StardewModdingAPI.Web.ViewModels.JsonValidator +{ + /// <summary>The view model for a JSON validation request.</summary> + public class JsonValidatorRequestModel + { + /********* + ** Accessors + *********/ + /// <summary>The schema name with which to validate the JSON.</summary> + public string SchemaName { get; set; } + + /// <summary>The raw content to validate.</summary> + public string Content { get; set; } + } +} diff --git a/src/SMAPI.Web/ViewModels/LogParserModel.cs b/src/SMAPI.Web/ViewModels/LogParserModel.cs index 41864c99..f4c5214b 100644 --- a/src/SMAPI.Web/ViewModels/LogParserModel.cs +++ b/src/SMAPI.Web/ViewModels/LogParserModel.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; +using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework.LogParsing.Models; namespace StardewModdingAPI.Web.ViewModels @@ -24,6 +25,9 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The paste ID.</summary> public string PasteID { get; set; } + /// <summary>The viewer's detected OS, if known.</summary> + public Platform? DetectedPlatform { get; set; } + /// <summary>The parsed log info.</summary> public ParsedLog ParsedLog { get; set; } @@ -46,24 +50,25 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>Construct an instance.</summary> /// <param name="sectionUrl">The root URL for the log parser controller.</param> /// <param name="pasteID">The paste ID.</param> - public LogParserModel(string sectionUrl, string pasteID) + /// <param name="platform">The viewer's detected OS, if known.</param> + public LogParserModel(string sectionUrl, string pasteID, Platform? platform) { this.SectionUrl = sectionUrl; this.PasteID = pasteID; + this.DetectedPlatform = platform; this.ParsedLog = null; this.ShowRaw = false; } - /// <summary>Construct an instance.</summary> - /// <param name="sectionUrl">The root URL for the log parser controller.</param> - /// <param name="pasteID">The paste ID.</param> + /// <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> - public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog, bool showRaw) - : this(sectionUrl, pasteID) + public LogParserModel SetResult(ParsedLog parsedLog, bool showRaw) { this.ParsedLog = parsedLog; this.ShowRaw = showRaw; + + return this; } /// <summary>Get all content packs in the log grouped by the mod they're for.</summary> @@ -81,7 +86,7 @@ namespace StardewModdingAPI.Web.ViewModels .ToDictionary(group => group.Key, group => group.ToArray()); } - /// <summary>Get a sanitised mod name that's safe to use in anchors, attributes, and URLs.</summary> + /// <summary>Get a sanitized mod name that's safe to use in anchors, attributes, and URLs.</summary> /// <param name="modName">The mod name.</param> public string GetSlug(string modName) { diff --git a/src/SMAPI.Web/ViewModels/ModListModel.cs b/src/SMAPI.Web/ViewModels/ModListModel.cs index 3b87d393..ff7513bc 100644 --- a/src/SMAPI.Web/ViewModels/ModListModel.cs +++ b/src/SMAPI.Web/ViewModels/ModListModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -18,19 +19,35 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The mods to display.</summary> public ModModel[] Mods { get; set; } + /// <summary>When the data was last updated.</summary> + public DateTimeOffset LastUpdated { get; set; } + + /// <summary>Whether the data hasn't been updated in a while.</summary> + public bool IsStale { get; set; } + + /// <summary>Whether the mod metadata is available.</summary> + public bool HasData => this.Mods != null; + /********* ** 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> - public ModListModel(string stableVersion, string betaVersion, IEnumerable<ModModel> mods) + /// <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) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; this.Mods = mods.ToArray(); + this.LastUpdated = lastUpdated; + this.IsStale = isStale; } } } diff --git a/src/SMAPI.Web/ViewModels/ModModel.cs b/src/SMAPI.Web/ViewModels/ModModel.cs index 8668f67b..2b478c81 100644 --- a/src/SMAPI.Web/ViewModels/ModModel.cs +++ b/src/SMAPI.Web/ViewModels/ModModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; using StardewModdingAPI.Toolkit.Framework.Clients.Wiki; @@ -37,6 +38,12 @@ namespace StardewModdingAPI.Web.ViewModels /// <summary>The human-readable warnings for players about this mod.</summary> public string[] Warnings { get; set; } + /// <summary>Extra metadata links (usually for open pull requests).</summary> + public Tuple<Uri, string>[] MetadataLinks { get; set; } + + /// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary> + public string DevNote { get; set; } + /// <summary>A unique identifier for the mod that can be used in an anchor URL.</summary> public string Slug { get; set; } @@ -61,6 +68,8 @@ namespace StardewModdingAPI.Web.ViewModels this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null; this.ModPages = this.GetModPageUrls(entry).ToArray(); this.Warnings = entry.Warnings; + this.MetadataLinks = entry.MetadataLinks; + this.DevNote = entry.DevNote; this.Slug = entry.Anchor; } @@ -91,15 +100,20 @@ namespace StardewModdingAPI.Web.ViewModels anyFound = true; yield return new ModLinkModel($"https://www.nexusmods.com/stardewvalley/mods/{entry.NexusID}", "Nexus"); } - if (entry.ChucklefishID.HasValue) + if (entry.ModDropID.HasValue) { anyFound = true; - yield return new ModLinkModel($"https://community.playstarbound.com/resources/{entry.ChucklefishID}", "Chucklefish"); + yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop"); } - if (entry.ModDropID.HasValue) + if (!string.IsNullOrWhiteSpace(entry.CurseForgeKey)) { anyFound = true; - yield return new ModLinkModel($"https://www.moddrop.com/sdv/mod/{entry.ModDropID}", "ModDrop"); + yield return new ModLinkModel($"https://www.curseforge.com/stardewvalley/mods/{entry.CurseForgeKey}", "CurseForge"); + } + if (entry.ChucklefishID.HasValue) + { + anyFound = true; + yield return new ModLinkModel($"https://community.playstarbound.com/resources/{entry.ChucklefishID}", "Chucklefish"); } // fallback diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index 249dc9d1..f42dde3b 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -52,7 +52,7 @@ <h2 id="help">Get help</h2> <ul> <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility list</a></li> - <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li> + <li>Get help <a href="https://smapi.io/community">on Discord or in the forums</a></li> </ul> @if (Model.BetaVersion == null) @@ -105,13 +105,12 @@ else <p> Special thanks to - <a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>, - <a href="https://github.com/dittusch">dittusch</a>, hawkfalcon, <a href="https://twitter.com/iKeychain">iKeychain</a>, jwdred, <a href="https://www.nexusmods.com/users/12252523">Karmylla</a>, Pucklynn, + Renorien, Robby LaFarge, and a few anonymous users for their ongoing support on Patreon; you're awesome! </p> @@ -124,5 +123,5 @@ else <li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li> } <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li> - <li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li> + <li>Need help? Come <a href="https://smapi.io/community">chat on Discord</a>.</li> </ul> diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml index ca99eef6..914384a8 100644 --- a/src/SMAPI.Web/Views/Index/Privacy.cshtml +++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml @@ -24,12 +24,12 @@ <p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p> <h3>Update checks</h3> -<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your SMAPI and mod versions to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> +<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> <p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p> <ol> <li><a href="https://stardewvalleywiki.com/Modding:Game_folder">find your game folder</a>;</li> - <li>open the <code>smapi-internal/StardewModdingAPI.config.json</code> file in a text editor;</li> + <li>open the <code>smapi-internal/config.json</code> file in a text editor;</li> <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li> </ol> diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml new file mode 100644 index 00000000..3143fad9 --- /dev/null +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -0,0 +1,151 @@ +@using StardewModdingAPI.Web.ViewModels.JsonValidator +@model JsonValidatorModel + +@{ + // get view data + string curPageUrl = new Uri(new Uri(Model.SectionUrl), $"{Model.SchemaName}/{Model.PasteID}").ToString(); + string newUploadUrl = Model.SchemaName != null ? new Uri(new Uri(Model.SectionUrl), Model.SchemaName).ToString() : Model.SectionUrl; + string schemaDisplayName = null; + bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName != "None"; + + // build title + ViewData["Title"] = "JSON validator"; + @if (Model.PasteID != null) + { + ViewData["ViewTitle"] = ViewData["Title"]; + ViewData["Title"] += + " (" + + string.Join(", ", new[] { isValidSchema ? schemaDisplayName : null, Model.PasteID }.Where(p => p != null)) + + ")"; + } +} + +@section Head { + @if (Model.PasteID != null) + { + <meta name="robots" content="noindex" /> + } + <link rel="stylesheet" href="~/Content/css/json-validator.css" /> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" /> + + <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script> + <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script> + <script src="~/Content/js/json-validator.js"></script> + <script> + $(function () { + smapi.jsonValidator(@Json.Serialize(Model.SectionUrl), @Json.Serialize(Model.PasteID)); + }); + </script> +} + +@* upload result banner *@ +@if (Model.UploadError != null) +{ + <div class="banner error"> + <strong>Oops, the server ran into trouble saving that file.</strong><br /> + <small>Error details: @Model.UploadError</small> + </div> +} +else if (Model.ParseError != null) +{ + <div class="banner error"> + <strong>Oops, couldn't parse that JSON.</strong><br /> + Share this link to let someone see this page: <code>@curPageUrl</code><br /> + (Or <a href="@newUploadUrl">validate a new file</a>.)<br /> + <br /> + <small v-pre>Error details: @Model.ParseError</small> + </div> +} +else if (Model.PasteID != null) +{ + <div class="banner success"> + <strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br /> + (Or <a href="@newUploadUrl">validate a new file</a>.) + </div> +} + +@* upload new file *@ +@if (Model.Content == null) +{ + <h2>Upload a JSON file</h2> + <form action="@Model.SectionUrl" method="post"> + <ol> + <li> + Choose the JSON format:<br /> + <select id="format" name="SchemaName"> + @foreach (var pair in Model.SchemaFormats) + { + <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> + } + </select> + </li> + <li> + Drag the file onto this textbox (or paste the text in):<br /> + <textarea id="input" name="Content" placeholder="paste file here"></textarea> + </li> + <li> + Click this button:<br /> + <input type="submit" id="submit" value="save & validate file" /> + </li> + </ol> + </form> +} + +@* validation results *@ +@if (Model.Content != null) +{ + <div id="output"> + @if (Model.UploadError == null) + { + <div> + Change JSON format: + <select id="format" name="format"> + @foreach (var pair in Model.SchemaFormats) + { + <option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> + } + </select> + </div> + + <h2>Validation errors</h2> + @if (Model.FormatUrl != null) + { + <p>See <a href="@Model.FormatUrl">format documentation</a>.</p> + } + + @if (Model.Errors.Any()) + { + <table id="metadata" class="table"> + <tr> + <th>Line</th> + <th>Field</th> + <th>Error</th> + </tr> + + @foreach (JsonValidatorErrorModel error in Model.Errors) + { + <tr data-schema-error="@error.SchemaErrorType"> + <td><a href="#L@(error.Line)">@error.Line</a></td> + <td>@error.Path</td> + <td>@error.Message</td> + </tr> + } + </table> + } + else + { + <p>No errors found.</p> + } + } + + <h2>Content</h2> + <pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre> + + @if (isValidSchema) + { + <p class="footer-tip">(Tip: you can <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/technical/web.md#using-a-schema-file-directly">validate directly in your text editor</a> if it supports JSON Schema.)</p> + } + </div> +} diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index 1b40cfa9..f98ffdf9 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -1,4 +1,5 @@ @using Newtonsoft.Json +@using StardewModdingAPI.Toolkit.Utilities @using StardewModdingAPI.Web.Framework.LogParsing.Models @model StardewModdingAPI.Web.ViewModels.LogParserModel @@ -67,12 +68,15 @@ else if (Model.ParsedLog?.IsValid == true) <h2>Where do I find my SMAPI log?</h2> <div>What system do you use?</div> <ul id="os-list"> - <li><input type="radio" name="os" value="android" id="os-android" /> <label for="os-android">Android</label></li> - <li><input type="radio" name="os" value="linux" id="os-linux" /> <label for="os-linux">Linux</label></li> - <li><input type="radio" name="os" value="mac" id="os-mac" /> <label for="os-mac">Mac</label></li> - <li><input type="radio" name="os" value="windows" id="os-windows" /> <label for="os-windows">Windows</label></li> + @foreach (Platform platform in new[] { Platform.Android, Platform.Linux, Platform.Mac, Platform.Windows }) + { + <li> + <input type="radio" name="os" value="@platform" id="os-@platform" checked="@(Model.DetectedPlatform == platform)"/> + <label for="os-@platform">@platform</label> + </li> + } </ul> - <div data-os="android"> + <div data-os="@Platform.Android"> On Android: <ol> <li>Open a file app (like My Files or MT Manager).</li> @@ -81,7 +85,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="linux"> + <div data-os="@Platform.Linux"> On Linux: <ol> <li>Open the Files app.</li> @@ -91,7 +95,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="mac"> + <div data-os="@Platform.Mac"> On Mac: <ol> <li>Open the Finder app.</li> @@ -100,7 +104,7 @@ else if (Model.ParsedLog?.IsValid == true) <li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li> </ol> </div> - <div data-os="windows"> + <div data-os="@Platform.Windows"> On Windows: <ol> <li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li> @@ -118,7 +122,7 @@ else if (Model.ParsedLog?.IsValid == true) </li> <li> Click this button:<br /> - <input type="submit" id="submit" value="save log" /> + <input type="submit" id="submit" value="save & parse log" /> </li> <li>On the new page, copy the URL and send it to the person helping you.</li> </ol> diff --git a/src/SMAPI.Web/Views/Mods/Index.cshtml b/src/SMAPI.Web/Views/Mods/Index.cshtml index 8293fbe2..50b59b45 100644 --- a/src/SMAPI.Web/Views/Mods/Index.cshtml +++ b/src/SMAPI.Web/Views/Mods/Index.cshtml @@ -1,7 +1,11 @@ +@using Humanizer +@using Humanizer.Localisation @using Newtonsoft.Json @model StardewModdingAPI.Web.ViewModels.ModListModel @{ ViewData["Title"] = "Mod compatibility"; + + TimeSpan staleAge = DateTimeOffset.UtcNow - Model.LastUpdated; } @section Head { <link rel="stylesheet" href="~/Content/css/mods.css?r=20190302" /> @@ -18,83 +22,104 @@ </script> } -<div id="app"> - <div id="intro"> - <p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#Ask_for_help">ask for help</a>.</p> +@if (!Model.HasData) +{ + <div class="error">↻ The mod data hasn't been fetched yet; please try again in a few minutes.</div> +} +else +{ + @if (Model.IsStale) + { + <div class="error">Showing data from @staleAge.Humanize(maxUnit: TimeUnit.Hour, minUnit: TimeUnit.Minute) ago. (Couldn't fetch newer data; the wiki API may be offline.)</div> + } - <p>The list is updated every few days (you can help <a href="https://stardewvalleywiki.com/Modding:Mod_compatibility">update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p> + <div id="app"> + <div id="intro"> + <p>This page shows all known SMAPI mods and (incompatible) content packs, whether they work with the latest versions of Stardew Valley and SMAPI, and how to fix them if not. If a mod doesn't work after following the instructions below, check <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting">the troubleshooting guide</a> or <a href="https://stardewvalleywiki.com/Modding:Player_Guide/Troubleshooting#Ask_for_help">ask for help</a>.</p> - @if (Model.BetaVersion != null) - { - <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "SDV @Model.BetaVersion only" lines are for an unreleased version of the game, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of the game.</p> - } - </div> + <p>The list is updated every few days (you can help <a href="https://stardewvalleywiki.com/Modding:Mod_compatibility">update it</a>!). It doesn't include XNB mods (see <a href="https://stardewvalleywiki.com/Modding:Using_XNB_mods"><em>using XNB mods</em> on the wiki</a> instead) or compatible content packs.</p> - <div id="options"> - <div> - <label for="search-box">Search: </label> - <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" /> + @if (Model.BetaVersion != null) + { + <p id="beta-blurb" v-show="showAdvanced"><strong>Note:</strong> "SDV @Model.BetaVersion only" lines are for an unreleased version of the game, not the stable version most players have. If a mod doesn't have that line, the info applies to both versions of the game.</p> + } </div> - <div id="filter-area"> - <input type="checkbox" id="show-advanced" v-model="showAdvanced" /> - <label for="show-advanced">show advanced info and options</label> - <div id="filters" v-show="showAdvanced"> - <div v-for="(filterGroup, key) in filters"> - {{filterGroup.label}}: <span v-for="filter in filterGroup.value" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span> + + <div id="options"> + <div> + <label for="search-box">Search: </label> + <input type="text" id="search-box" v-model="search" v-on:input="applyFilters" /> + </div> + <div id="filter-area"> + <input type="checkbox" id="show-advanced" v-model="showAdvanced" /> + <label for="show-advanced">show advanced info and options</label> + <div id="filters" v-show="showAdvanced"> + <div v-for="(filterGroup, key) in filters"> + {{filterGroup.label}}: <span v-for="filter in filterGroup.value" v-bind:class="{ active: filter.value }"><input type="checkbox" v-bind:id="filter.id" v-model="filter.value" v-on:change="applyFilters" /> <label v-bind:for="filter.id">{{filter.label}}</label></span> + </div> </div> </div> </div> - </div> - <div id="mod-count" v-show="showAdvanced"> - <div v-if="visibleStats.total > 0"> - {{visibleStats.total}} mods shown ({{Math.round((visibleStats.compatible + visibleStats.workaround) / visibleStats.total * 100)}}% compatible or have a workaround, {{Math.round((visibleStats.soon + visibleStats.broken) / visibleStats.total * 100)}}% broken, {{Math.round(visibleStats.abandoned / visibleStats.total * 100)}}% obsolete). + <div id="mod-count" v-show="showAdvanced"> + <div v-if="visibleStats.total > 0"> + {{visibleStats.total}} mods shown ({{Math.round((visibleStats.compatible + visibleStats.workaround) / visibleStats.total * 100)}}% compatible or have a workaround, {{Math.round((visibleStats.soon + visibleStats.broken) / visibleStats.total * 100)}}% broken, {{Math.round(visibleStats.abandoned / visibleStats.total * 100)}}% obsolete). + </div> + <span v-else>No matching mods found.</span> </div> - <span v-else>No matching mods found.</span> + <table class="wikitable" id="mod-list"> + <thead> + <tr> + <th>mod name</th> + <th>links</th> + <th>author</th> + <th>compatibility</th> + <th v-show="showAdvanced">broke in</th> + <th v-show="showAdvanced">code</th> + <th> </th> + </tr> + </thead> + <tbody> + <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> + <td> + {{mod.Name}} + <small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small> + </td> + <td class="mod-page-links"> + <span v-for="(link, i) in mod.ModPages"> + <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} + </span> + </td> + <td> + {{mod.Author}} + <small class="mod-alt-authors" v-if="mod.AlternateAuthors">(aka {{mod.AlternateAuthors}})</small> + </td> + <td> + <div v-html="mod.Compatibility.Summary"></div> + <div v-if="mod.BetaCompatibility" v-show="showAdvanced"> + <strong v-if="mod.BetaCompatibility">SDV @Model.BetaVersion only:</strong> + <span v-html="mod.BetaCompatibility.Summary"></span> + </div> + <div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div> + </td> + <td class="mod-broke-in" v-html="mod.LatestCompatibility.BrokeIn" v-show="showAdvanced"></td> + <td v-show="showAdvanced"> + <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span> + <span v-else class="mod-closed-source">no source</span> + </td> + <td> + <small> + <a v-bind:href="'#' + mod.Slug">#</a> + <span v-show="showAdvanced"> + <template v-for="(link, i) in mod.MetadataLinks"> + <a v-bind:href="link.Item1">{{link.Item2}}</a> + </template> + + <abbr v-bind:title="mod.DevNote" v-show="mod.DevNote">[dev note]</abbr> + </span> + </small> + </td> + </tr> + </tbody> + </table> </div> - <table class="wikitable" id="mod-list"> - <thead> - <tr> - <th>mod name</th> - <th>links</th> - <th>author</th> - <th>compatibility</th> - <th v-show="showAdvanced">broke in</th> - <th v-show="showAdvanced">code</th> - <th> </th> - </tr> - </thead> - <tbody> - <tr v-for="mod in mods" :key="mod.Name" v-bind:id="mod.Slug" :key="mod.Slug" v-bind:data-status="mod.Compatibility.Status" v-show="mod.Visible"> - <td> - {{mod.Name}} - <small class="mod-alt-names" v-if="mod.AlternateNames">(aka {{mod.AlternateNames}})</small> - </td> - <td class="mod-page-links"> - <span v-for="(link, i) in mod.ModPages"> - <a v-bind:href="link.Url">{{link.Text}}</a>{{i < mod.ModPages.length - 1 ? ', ' : ''}} - </span> - </td> - <td> - {{mod.Author}} - <small class="mod-alt-authors" v-if="mod.AlternateAuthors">(aka {{mod.AlternateAuthors}})</small> - </td> - <td> - <div v-html="mod.Compatibility.Summary"></div> - <div v-if="mod.BetaCompatibility" v-show="showAdvanced"> - <strong v-if="mod.BetaCompatibility">SDV @Model.BetaVersion only:</strong> - <span v-html="mod.BetaCompatibility.Summary"></span> - </div> - <div v-for="(warning, i) in mod.Warnings">⚠ {{warning}}</div> - </td> - <td class="mod-broke-in" v-html="mod.LatestCompatibility.BrokeIn" v-show="showAdvanced"></td> - <td v-show="showAdvanced"> - <span v-if="mod.SourceUrl"><a v-bind:href="mod.SourceUrl">source</a></span> - <span v-else class="mod-closed-source">no source</span> - </td> - <td> - <small><a v-bind:href="'#' + mod.Slug">#</a></small> - </td> - </tr> - </tbody> - </table> -</div> +} diff --git a/src/SMAPI.Web/Views/Shared/_Layout.cshtml b/src/SMAPI.Web/Views/Shared/_Layout.cshtml index 4c602b29..87a22f06 100644 --- a/src/SMAPI.Web/Views/Shared/_Layout.cshtml +++ b/src/SMAPI.Web/Views/Shared/_Layout.cshtml @@ -16,14 +16,19 @@ <h4>SMAPI</h4> <ul> <li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li> + <li><a href="https://stardewvalleywiki.com/Modding:Index">Modding docs</a></li> + </ul> + + <h4>Tools</h4> + <ul> <li><a href="@SiteConfig.Value.ModListUrl">Mod compatibility</a></li> <li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li> - <li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li> + <li><a href="@SiteConfig.Value.JsonValidatorUrl">JSON validator</a></li> </ul> </div> <div id="content-column"> <div id="content"> - <h1>@ViewData["Title"]</h1> + <h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1> @RenderBody() </div> <div id="footer"> diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 49234a3b..baf7efb7 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -8,19 +8,11 @@ */ { - "Logging": { - "IncludeScopes": false, - "LogLevel": { - "Default": "Debug", - "System": "Information", - "Microsoft": "Information" - } - }, - "Site": { "RootUrl": "http://localhost:59482/", "ModListUrl": "http://localhost:59482/mods/", "LogParserUrl": "http://localhost:59482/log/", + "JsonValidatorUrl": "http://localhost:59482/json/", "BetaEnabled": false, "BetaBlurb": null }, @@ -29,7 +21,16 @@ "GitHubUsername": null, "GitHubPassword": null, + "NexusApiKey": null, + "PastebinUserKey": null, "PastebinDevKey": null + }, + + "MongoDB": { + "Host": "localhost", + "Username": null, + "Password": null, + "Database": "smapi-edge" } } diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 9e15aa97..674bb672 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -10,7 +10,8 @@ "Logging": { "IncludeScopes": false, "LogLevel": { - "Default": "Warning" + "Default": "Warning", + "Hangfire": "Information" } }, @@ -18,6 +19,7 @@ "RootUrl": null, // see top note "ModListUrl": null, // see top note "LogParserUrl": null, // see top note + "JsonValidatorUrl": null, // see top note "BetaEnabled": null, // see top note "BetaBlurb": null // see top note }, @@ -28,9 +30,9 @@ "ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishModPageUrlFormat": "resources/{0}", + "CurseForgeBaseUrl": "https://addons-ecs.forgesvc.net/api/v2/", + "GitHubBaseUrl": "https://api.github.com", - "GitHubStableReleaseUrlFormat": "repos/{0}/releases/latest", - "GitHubAnyReleaseUrlFormat": "repos/{0}/releases?per_page=2", // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials) "GitHubAcceptHeader": "application/vnd.github.v3+json", "GitHubUsername": null, // see top note "GitHubPassword": null, // see top note @@ -38,6 +40,7 @@ "ModDropApiUrl": "https://www.moddrop.com/api/mods/data", "ModDropModPageUrl": "https://www.moddrop.com/sdv/mod/{0}", + "NexusApiKey": null, // see top note "NexusBaseUrl": "https://www.nexusmods.com/stardewvalley/", "NexusModUrlFormat": "mods/{0}", "NexusModScrapeUrlFormat": "mods/{0}?tab=files", @@ -47,14 +50,24 @@ "PastebinDevKey": null // see top note }, + "MongoDB": { + "Host": null, // see top note + "Username": null, // see top note + "Password": null, // see top note + "Database": null // see top note + }, + "ModCompatibilityList": { - "CacheMinutes": 10 + "StaleMinutes": 15 + }, + + "BackgroundServices": { + "Enabled": true }, "ModUpdateCheck": { "SuccessCacheMinutes": 60, "ErrorCacheMinutes": 5, - "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$", "CompatibilityPageUrl": "https://mods.smapi.io" } } diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css new file mode 100644 index 00000000..cd117694 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css @@ -0,0 +1,111 @@ +/********* +** Main layout +*********/ +#content { + max-width: 100%; +} + +#output { + padding: 10px; + overflow: auto; +} + +#output table td { + font-family: monospace; +} + +#output table tr th, +#output table tr td { + padding: 0 0.75rem; + white-space: pre-wrap; +} + + +/********* +** Result banner +*********/ +.banner { + border: 2px solid gray; + border-radius: 5px; + margin-top: 1em; + padding: 1em; +} + +.banner.success { + border-color: green; + background: #CFC; +} + +.banner.error { + border-color: red; + background: #FCC; +} + +/********* +** Validation results +*********/ +.table { + border-bottom: 1px dashed #888888; + margin-bottom: 5px; +} + +#metadata th, #metadata td { + text-align: left; + padding-right: 0.7em; +} + +.table { + border: 1px solid #000000; + background: #ffffff; + border-radius: 5px; + border-spacing: 1px; + overflow: hidden; + cursor: default; + box-shadow: 1px 1px 1px 1px #dddddd; +} + +.table tr { + background: #eee; +} + +.table tr:nth-child(even) { + background: #fff; +} + +#output div.sunlight-line-highlight-active { + background-color: #eeeacc; +} + +.footer-tip { + color: gray; + font-size: 0.9em; +} + +.footer-tip a { + color: gray; +} + +/********* +** Upload form +*********/ +#input { + width: 100%; + height: 20em; + max-height: 70%; + margin: auto; + box-sizing: border-box; + border-radius: 5px; + border: 1px solid #000088; + outline: none; + box-shadow: inset 0px 0px 1px 1px rgba(0, 0, 192, .2); +} + +#submit { + font-size: 1.5em; + border-radius: 5px; + outline: none; + box-shadow: inset 0 0 1px 1px rgba(0, 0, 0, .2); + cursor: pointer; + border: 1px solid #008800; + background-color: #cfc; +} diff --git a/src/SMAPI.Web/wwwroot/Content/css/main.css b/src/SMAPI.Web/wwwroot/Content/css/main.css index 57eeee88..dcc7a798 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/main.css +++ b/src/SMAPI.Web/wwwroot/Content/css/main.css @@ -73,7 +73,7 @@ a { } #sidebar h4 { - margin: 0 0 0.2em 0; + margin: 1.5em 0 0.2em 0; width: 10em; border-bottom: 1px solid #CCC; font-size: 0.8em; diff --git a/src/SMAPI.Web/wwwroot/Content/css/mods.css b/src/SMAPI.Web/wwwroot/Content/css/mods.css index fc5fff47..1c2b8056 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/mods.css +++ b/src/SMAPI.Web/wwwroot/Content/css/mods.css @@ -15,30 +15,6 @@ border: 3px solid darkgreen; } -table.wikitable { - background-color:#f8f9fa; - color:#222; - border:1px solid #a2a9b1; - border-collapse:collapse -} - -table.wikitable > tr > th, -table.wikitable > tr > td, -table.wikitable > * > tr > th, -table.wikitable > * > tr > td { - border:1px solid #a2a9b1; - padding:0.2em 0.4em -} - -table.wikitable > tr > th, -table.wikitable > * > tr > th { - background-color:#eaecf0; -} - -table.wikitable > caption { - font-weight:bold -} - #options { margin-bottom: 1em; } @@ -73,6 +49,39 @@ table.wikitable > caption { opacity: 0.5; } +div.error { + padding: 2em 0; + color: red; + font-weight: bold; +} + +/********* +** Mod list +*********/ +table.wikitable { + background-color:#f8f9fa; + color:#222; + border:1px solid #a2a9b1; + border-collapse:collapse +} + +table.wikitable > tr > th, +table.wikitable > tr > td, +table.wikitable > * > tr > th, +table.wikitable > * > tr > td { + border:1px solid #a2a9b1; + padding:0.2em 0.4em +} + +table.wikitable > tr > th, +table.wikitable > * > tr > th { + background-color:#eaecf0; +} + +table.wikitable > caption { + font-weight:bold +} + #mod-list { font-size: 0.9em; } diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js new file mode 100644 index 00000000..5499cef6 --- /dev/null +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -0,0 +1,179 @@ +/* globals $ */ + +var smapi = smapi || {}; + +/** + * Manages the logic for line range selections. + * @param {int} maxLines The maximum number of lines in the content. + */ +smapi.LineNumberRange = function (maxLines) { + var self = this; + + /** + * @var {int} minLine The first line in the selection, or null if no lines selected. + */ + self.minLine = null; + + /** + * @var {int} maxLine The last line in the selection, or null if no lines selected. + */ + self.maxLine = null; + + /** + * Parse line numbers from a URL hash. + * @param {string} hash the URL hash to parse. + */ + self.parseFromUrlHash = function (hash) { + self.minLine = null; + self.maxLine = null; + + // parse hash + var hashParts = hash.match(/^#L(\d+)(?:-L(\d+))?$/); + if (!hashParts || hashParts.length <= 1) + return; + + // extract min/max lines + self.minLine = parseInt(hashParts[1]); + self.maxLine = parseInt(hashParts[2]) || self.minLine; + }; + + /** + * Generate a URL hash for the current line range. + * @returns {string} The generated URL hash. + */ + self.buildHash = function() { + if (!self.minLine) + return ""; + else if (self.minLine === self.maxLine) + return "#L" + self.minLine; + else + return "#L" + self.minLine + "-L" + self.maxLine; + } + + /** + * Get a list of all selected lines. + * @returns {Array<int>} The selected line numbers. + */ + self.getLinesSelected = function() { + // format + if (!self.minLine) + return []; + + var lines = []; + for (var i = self.minLine; i <= self.maxLine; i++) + lines.push(i); + return lines; + }; + + return self; +}; + +/** + * UI logic for the JSON validator page. + * @param {any} sectionUrl The base JSON validator page URL. + * @param {any} pasteID The Pastebin paste ID for the content being viewed, if any. + */ +smapi.jsonValidator = function (sectionUrl, pasteID) { + /** + * The original content element. + */ + var originalContent = $("#raw-content").clone(); + + /** + * The currently highlighted lines. + */ + var selection = new smapi.LineNumberRange(); + + /** + * Rebuild the syntax-highlighted element. + */ + var formatCode = function () { + // reset if needed + $(".sunlight-container").replaceWith(originalContent.clone()); + + // apply default highlighting + Sunlight.highlightAll({ + lineHighlight: selection.getLinesSelected() + }); + + // fix line links + $(".sunlight-line-number-margin a").each(function() { + var link = $(this); + var lineNumber = parseInt(link.text()); + link + .attr("id", "L" + lineNumber) + .attr("href", "#L" + lineNumber) + .removeAttr("name") + .data("line-number", lineNumber); + }); + }; + + /** + * Scroll the page so the selected range is visible. + */ + var scrollToRange = function() { + if (!selection.minLine) + return; + + var targetLine = Math.max(1, selection.minLine - 5); + $("#L" + targetLine).get(0).scrollIntoView(); + }; + + /** + * Initialize the JSON validator page. + */ + var init = function () { + // set initial code formatting + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + + // update code formatting on hash change + $(window).on("hashchange", function() { + selection.parseFromUrlHash(location.hash); + formatCode(); + scrollToRange(); + }); + + // change format + $("#output #format").on("change", function() { + var schemaName = $(this).val(); + location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString(); + }); + + // upload form + var input = $("#input"); + if (input.length) { + // disable submit if it's empty + var toggleSubmit = function () { + var hasText = !!input.val().trim(); + submit.prop("disabled", !hasText); + }; + input.on("input", toggleSubmit); + toggleSubmit(); + + // drag & drop file + input.on({ + 'dragover dragenter': function (e) { + e.preventDefault(); + e.stopPropagation(); + }, + 'drop': function (e) { + var dataTransfer = e.originalEvent.dataTransfer; + if (dataTransfer && dataTransfer.files.length) { + e.preventDefault(); + e.stopPropagation(); + var file = dataTransfer.files[0]; + var reader = new FileReader(); + reader.onload = $.proxy(function (file, $input, event) { + $input.val(event.target.result); + toggleSubmit(); + }, this, file, $("#input")); + reader.readAsText(file); + } + } + }); + } + }; + init(); +}; diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js index e87a1a5c..e6c7591c 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js +++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js @@ -23,7 +23,7 @@ smapi.logParser = function (data, sectionUrl) { } // set local time started - if(data) + if (data) data.localTimeStarted = ("0" + data.logStarted.getHours()).slice(-2) + ":" + ("0" + data.logStarted.getMinutes()).slice(-2); // init app @@ -100,7 +100,7 @@ smapi.logParser = function (data, sectionUrl) { updateModFilters(); }, - filtersAllow: function(modId, level) { + filtersAllow: function (modId, level) { return this.showMods[modId] !== false && this.showLevels[level] !== false; }, @@ -121,16 +121,15 @@ smapi.logParser = function (data, sectionUrl) { var submit = $("#submit"); // instruction OS chooser - var chooseSystem = function() { + var chooseSystem = function () { systemInstructions.hide(); systemInstructions.filter("[data-os='" + $("input[name='os']:checked").val() + "']").show(); - } + }; systemOptions.on("click", chooseSystem); chooseSystem(); // disable submit if it's empty - var toggleSubmit = function() - { + var toggleSubmit = function () { var hasText = !!input.val().trim(); submit.prop("disabled", !hasText); } @@ -139,18 +138,18 @@ smapi.logParser = function (data, sectionUrl) { // drag & drop file input.on({ - 'dragover dragenter': function(e) { + 'dragover dragenter': function (e) { e.preventDefault(); e.stopPropagation(); }, - 'drop': function(e) { + 'drop': function (e) { var dataTransfer = e.originalEvent.dataTransfer; if (dataTransfer && dataTransfer.files.length) { e.preventDefault(); e.stopPropagation(); var file = dataTransfer.files[0]; var reader = new FileReader(); - reader.onload = $.proxy(function(file, $input, event) { + reader.onload = $.proxy(function (file, $input, event) { $input.val(event.target.result); toggleSubmit(); }, this, file, $("#input")); diff --git a/src/SMAPI.Web/wwwroot/Content/js/mods.js b/src/SMAPI.Web/wwwroot/Content/js/mods.js index 130f60be..0394ac4f 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/mods.js +++ b/src/SMAPI.Web/wwwroot/Content/js/mods.js @@ -44,6 +44,7 @@ smapi.modList = function (mods, enableBeta) { download: { value: { chucklefish: { value: true, label: "Chucklefish" }, + curseforge: { value: true, label: "CurseForge" }, moddrop: { value: true, label: "ModDrop" }, nexus: { value: true, label: "Nexus" }, custom: { value: true } @@ -180,6 +181,8 @@ smapi.modList = function (mods, enableBeta) { if (!filters.download.value.chucklefish.value) ignoreSites.push("Chucklefish"); + if (!filters.download.value.curseforge.value) + ignoreSites.push("CurseForge"); if (!filters.download.value.moddrop.value) ignoreSites.push("ModDrop"); if (!filters.download.value.nexus.value) diff --git a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json index d0c55552..78918bac 100644 --- a/src/SMAPI.Web/wwwroot/StardewModdingAPI.metadata.json +++ b/src/SMAPI.Web/wwwroot/SMAPI.metadata.json @@ -14,11 +14,6 @@ * other fields if no ID was specified. This doesn't include the latest ID, if any. Multiple * variants can be separated with '|'. * - * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions - * during update checks. For example, if the API returns version '1.1-1078' where '1078' is - * intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the - * mod's current version. This is only meant to support legacy mods with injected update keys. - * * Versioned metadata * ================== * Each record can also specify extra metadata using the field keys below. @@ -59,15 +54,15 @@ "Default | UpdateKey": "Nexus:2270" }, - "Content Patcher": { - "ID": "Pathoschild.ContentPatcher", - "Default | UpdateKey": "Nexus:1915" - }, + //"Content Patcher": { + // "ID": "Pathoschild.ContentPatcher", + // "Default | UpdateKey": "Nexus:1915" + //}, - "Custom Farming Redux": { - "ID": "Platonymous.CustomFarming", - "Default | UpdateKey": "Nexus:991" - }, + //"Custom Farming Redux": { + // "ID": "Platonymous.CustomFarming", + // "Default | UpdateKey": "Nexus:991" + //}, "Custom Shirts": { "ID": "Platonymous.CustomShirts", @@ -122,116 +117,175 @@ "Default | UpdateKey": "Nexus:1820" }, + /********* + ** Obsolete + *********/ + "Animal Mood Fix": { + "ID": "GPeters-AnimalMoodFix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + }, + + "Bee House Flower Range Fix": { + "ID": "kirbylink.beehousefix", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "the bee house flower range was fixed in Stardew Valley 1.4." + }, + + "Colored Chests": { + "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + }, + + "Modder Serialization Utility": { + "ID": "SerializerUtils-0-1", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "it's no longer maintained or used." + }, + + "No Debug Mode": { + "ID": "NoDebugMode", + "~ | Status": "Obsolete", + "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + }, /********* - ** Map versions + ** Broke in SDV 1.4 *********/ - "Adjust Artisan Prices": { - "ID": "ThatNorthernMonkey.AdjustArtisanPrices", - "FormerIDs": "1e36d4ca-c7ef-4dfb-9927-d27a6c3c8bdc", // changed in 0.0.2-pathoschild-update - "MapRemoteVersions": { "0.01": "0.0.1" } + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Fix Dice": { + "ID": "ashley.fixdice", + "~1.1.2 | Status": "AssumeBroken" // crashes game on startup + }, + + "Grass Growth": { + "ID": "bcmpinc.GrassGrowth", + "~1.0 | Status": "AssumeBroken" + }, + + "Invite Code Mod": { + "ID": "KOREJJamJar.InviteCodeMod", + "~1.0.1 | Status": "AssumeBroken" + }, + + "Loved Labels": { + "ID": "Advize.LovedLabels", + "~2.2.1-unofficial.2-pathoschild | Status": "AssumeBroken" }, - "Almighty Farming Tool": { - "ID": "439", - "MapRemoteVersions": { - "1.21": "1.2.1", - "1.22-unofficial.3.mizzion": "1.2.2-unofficial.3.mizzion" - } + "Neat Additions": { + "ID": "ilyaki.neatadditions", + "~1.0.3 | Status": "AssumeBroken" }, - "Basic Sprinkler Improved": { - "ID": "lrsk_sdvm_bsi.0117171308", - "MapRemoteVersions": { "1.0.2": "1.0.1-release" } // manifest not updated + "Remote Fridge Storage": { + "ID": "EternalSoap.RemoteFridgeStorage", + "~1.5 | Status": "AssumeBroken" }, - "Better Shipping Box": { - "ID": "Kithio:BetterShippingBox", - "MapLocalVersions": { "1.0.1": "1.0.2" } + "Stack Everything": { + "ID": "cat.stackeverything", + "~2.15 | Status": "AssumeBroken" }, - "Chefs Closet": { - "ID": "Duder.ChefsCloset", - "MapLocalVersions": { "1.3-1": "1.3" } + "Yet Another Harvest With Scythe Mod": { + "ID": "bcmpinc.HarvestWithScythe", + "~1.1 | Status": "AssumeBroken" }, - "Configurable Machines": { - "ID": "21da6619-dc03-4660-9794-8e5b498f5b97", - "MapLocalVersions": { "1.2-beta": "1.2" } + /********* + ** Broke in SMAPI 3.0 (runtime errors due to lifecycle changes) + *********/ + "Advancing Sprinklers": { + "ID": "warix3.advancingsprinklers", + "~1.0.0 | Status": "AssumeBroken" }, - "Crafting Counter": { - "ID": "lolpcgaming.CraftingCounter", - "MapRemoteVersions": { "1.1": "1.0" } // not updated in manifest + "Arcade 2048": { + "ID": "Platonymous.2048", + "~1.0.6 | Status": "AssumeBroken" // possibly due to PyTK }, - "Custom Linens": { - "ID": "Mevima.CustomLinens", - "MapRemoteVersions": { "1.1": "1.0" } // manifest not updated + "Arcade Snake": { + "ID": "Platonymous.Snake", + "~1.1.0 | Status": "AssumeBroken" // possibly due to PyTK }, - "Dynamic Horses": { - "ID": "Bpendragon-DynamicHorses", - "MapRemoteVersions": { "1.2": "1.1-release" } // manifest not updated + "Better Sprinklers": { + "ID": "Speeder.BetterSprinklers", + "~2.3.1-unofficial.7-pathoschild | Status": "AssumeBroken" }, - "Dynamic Machines": { - "ID": "DynamicMachines", - "MapLocalVersions": { "1.1": "1.1.1" } + "Content Patcher": { + "ID": "Pathoschild.ContentPatcher", + "Default | UpdateKey": "Nexus:1915", + "~1.6.4 | Status": "AssumeBroken" }, - "Multiple Sprites and Portraits On Rotation (File Loading)": { - "ID": "FileLoading", - "MapLocalVersions": { "1.1": "1.12" } + "Current Location (Vrakyas)": { + "ID": "Vrakyas.CurrentLocation", + "~1.5.4 | Status": "AssumeBroken" }, - "Relationship Status": { - "ID": "relationshipstatus", - "MapRemoteVersions": { "1.0.5": "1.0.4" } // not updated in manifest + "Custom Adventure Guild Challenges": { + "ID": "DefenTheNation.CustomGuildChallenges", + "~1.8 | Status": "AssumeBroken" }, - "ReRegeneration": { - "ID": "lrsk_sdvm_rerg.0925160827", - "MapLocalVersions": { "1.1.2-release": "1.1.2" } + "Custom Farming Redux": { + "ID": "Platonymous.CustomFarming", + "Default | UpdateKey": "Nexus:991", + "~2.10.10 | Status": "AssumeBroken" // possibly due to PyTK }, - "Showcase Mod": { - "ID": "Igorious.Showcase", - "MapLocalVersions": { "0.9-500": "0.9" } + "Decrafting Mod": { + "ID": "MSCFC.DecraftingMod", + "~1.0 | Status": "AssumeBroken" // NRE in ModEntry }, - "Siv's Marriage Mod": { - "ID": "6266959802", // official version - "FormerIDs": "Siv.MarriageMod | medoli900.Siv's Marriage Mod", // 1.2.3-unofficial versions - "MapLocalVersions": { "0.0": "1.4" } + "JoJaBan - Arcade Sokoban": { + "ID": "Platonymous.JoJaBan", + "~0.4.3 | Status": "AssumeBroken" // possibly due to PyTK }, + "Level Extender": { + "ID": "DevinLematty.LevelExtender", + "~3.1 | Status": "AssumeBroken" + }, - /********* - ** Obsolete - *********/ - "Animal Mood Fix": { - "ID": "GPeters-AnimalMoodFix", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "the animal mood bugs were fixed in Stardew Valley 1.2." + "Mod Update Menu": { + "ID": "cat.modupdatemenu", + "~1.4 | Status": "AssumeBroken" }, - "Colored Chests": { - "ID": "4befde5c-731c-4853-8e4b-c5cdf946805f", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "colored chests were added in Stardew Valley 1.1." + "Quick Start": { + "ID": "WuestMan.QuickStart", + "~1.5 | Status": "AssumeBroken" }, - "Modder Serialization Utility": { - "ID": "SerializerUtils-0-1", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "it's no longer maintained or used." + "Seed Bag": { + "ID": "Platonymous.SeedBag", + "~1.2.7 | Status": "AssumeBroken" // possibly due to PyTK }, - "No Debug Mode": { - "ID": "NoDebugMode", - "~ | Status": "Obsolete", - "~ | StatusReasonPhrase": "debug mode was removed in SMAPI 1.0." + "Stardew Valley ESP": { + "ID": "reimu.sdv-helper", + "~1.1 | Status": "AssumeBroken" + }, + + "Underdark Krobus": { + "ID": "melnoelle.underdarkkrobus", + "~1.0.0 | Status": "AssumeBroken" // NRE in ModEntry + }, + + "Underdark Sewer": { + "ID": "melnoelle.underdarksewer", + "~1.1.0 | Status": "AssumeBroken" // NRE in ModEntry }, /********* @@ -349,11 +403,6 @@ "~0.3 | Status": "AssumeBroken" // broke in 1.3: Exception from HarmonyInstance "bcmpinc.FixScytheExp" [...] Bad label content in ILGenerator. }, - "Grass Growth": { - "ID": "bcmpinc.GrassGrowth", - "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) - }, - "More Silo Storage": { "ID": "OrneryWalrus.MoreSiloStorage", "~1.0.1 | Status": "AssumeBroken" // broke in SDV 1.3 @@ -374,12 +423,6 @@ "~1.0.0 | Status": "AssumeBroken" // broke in Stardew Valley 1.3.29 (runtime errors) }, - "Skill Prestige: Cooking Adapter": { - "ID": "Alphablackwolf.CookingSkillPrestigeAdapter", - "FormerIDs": "20d6b8a3-b6e7-460b-a6e4-07c2b0cb6c63", // changed circa 1.1 - "MapRemoteVersions": { "1.2.3": "1.1" } // manifest not updated - }, - "Skull Cave Saver": { "ID": "cantorsdust.SkullCaveSaver", "FormerIDs": "8ac06349-26f7-4394-806c-95d48fd35774 | community.SkullCaveSaver", // changed in 1.1 and 1.2.2 @@ -398,7 +441,6 @@ "Stephan's Lots of Crops": { "ID": "stephansstardewcrops", - "MapRemoteVersions": { "1.41": "1.1" }, // manifest not updated "~1.1 | Status": "AssumeBroken" // broke in SDV 1.3 (overwrites vanilla items) }, @@ -418,11 +460,6 @@ "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) }, - "Yet Another Harvest With Scythe Mod": { - "ID": "bcmpinc.HarvestWithScythe", - "~0.6 | Status": "AssumeBroken" // breaks newer versions of bcmpinc mods (per bcmpinc's request) - }, - /********* ** Broke circa SDV 1.2 *********/ diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json new file mode 100644 index 00000000..61a633cb --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -0,0 +1,389 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/content-patcher.json", + "title": "Content Patcher content pack", + "description": "Content Patcher content file for mods", + "@documentationUrl": "https://github.com/Pathoschild/StardewMods/tree/develop/ContentPatcher#readme", + "type": "object", + + "properties": { + "Format": { + "title": "Format version", + "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", + "type": "string", + "const": "1.9", + "@errorMessages": { + "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.9'." + } + }, + "ConfigSchema": { + "title": "Config schema", + "description": "Defines the config.json format, to support more complex mods.", + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "AllowValues": { + "title": "Allowed values", + "description": "The values the player can provide, as a comma-delimited string. If omitted, any value is allowed.\nTip: for a boolean flag, use \"true, false\".", + "type": "string" + }, + "AllowBlank": { + "title": "Allow blank", + "description": "Whether the field can be left blank. If false or omitted, blank fields will be replaced with the default value.", + "type": "boolean" + }, + "AllowMultiple": { + "title": "Allow multiple values", + "description": "Whether the player can specify multiple comma-delimited values. Default false.", + "type": "boolean" + }, + "Default": { + "title": "Default value", + "description": "The default values when the field is missing. Can contain multiple comma-delimited values if AllowMultiple is true. If omitted, blank fields are left blank.", + "type": "string" + }, + + "additionalProperties": false + }, + "allOf": [ + { + "if": { + "properties": { + "AllowBlank": { "const": false } + }, + "required": [ "AllowBlank" ] + }, + "then": { + "required": [ "Default" ] + } + } + ], + + "@errorMessages": { + "allOf": "If 'AllowBlank' is false, the 'Default' field is required." + } + } + }, + "DynamicTokens": { + "title": "Dynamic tokens", + "description": "Custom tokens that you can use.", + "type": "array", + "items": { + "type": "object", + "properties": { + "Name": { + "title": "Name", + "description": "The name of the token to use for tokens & conditions.", + "type": "string" + }, + "Value": { + "title": "Token value", + "description": "The value(s) to set. This can be a comma-delimited value to give it multiple values. If any block for a token name has multiple values, it will only be usable in conditions. This field supports tokens, including dynamic tokens defined before this entry.", + "type": "string" + }, + "When": { + "title": "When", + "description": "Only set the value if the given conditions match. If not specified, always matches.", + "$ref": "#/definitions/Condition" + } + }, + + "required": [ "Name", "Value" ], + "additionalProperties": false + } + }, + "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.", + "type": "array", + "items": { + "properties": { + "Action": { + "title": "Action", + "description": "The kind of change to make.", + "type": "string", + "enum": [ "Load", "EditImage", "EditData", "EditMap" ] + }, + "Target": { + "title": "Target asset", + "description": "The game asset you want to patch (or multiple comma-delimited assets). This is the file path inside your game's Content folder, without the file extension or language (like Animals/Dinosaur to edit Content/Animals/Dinosaur.xnb). This field supports tokens and capitalization doesn't matter. Your changes are applied in all languages unless you specify a language condition.", + "type": "string", + "not": { + "pattern": "^ *[cC][oO][nN][tT][eE][nN][tT]/|\\.[xX][nN][bB] *$|\\.[a-zA-Z][a-zA-Z]-[a-zA-Z][a-zA-Z](?:.xnb)? *$" + }, + "@errorMessages": { + "not": "Invalid target; it shouldn't include the 'Content/' folder, '.xnb' extension, or locale code." + } + }, + "LogName": { + "title": "Patch log name", + "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 fields supports immutable tokens (e.g. config tokens) if they return true/false.", + "anyOf": [ + { + "type": "string", + "enum": [ "true", "false" ] + }, + { + "type": "string", + "pattern": "\\{\\{[^{}]+\\}\\}" + }, + { + "type": "boolean" + } + ], + "@errorMessages": { + "anyOf": "Invalid value; must be true, false, or a single token which evaluates to true or false." + } + }, + "FromFile": { + "title": "Source file", + "description": "The relative file path in your content pack folder to load instead (like 'assets/dinosaur.png'). This can be a .json (data), .png (image), .tbin (map), or .xnb file. This field supports tokens and capitalization doesn't matter.", + "type": "string", + "allOf": [ + { + "not": { + "pattern": "\b\\.\\.[/\\]" + } + }, + { + "pattern": "\\.(json|png|tbin|xnb) *$" + } + ], + "@errorMessages": { + "allOf:indexes: 0": "Invalid value; must not contain directory climbing (like '../').", + "allOf:indexes: 1": "Invalid value; must be a file path ending with .json, .png, .tbin, or .xnb." + } + }, + "FromArea": { + "title": "Source area", + "description": "The part of the source image to copy. Defaults to the whole source image.", + "$ref": "#/definitions/Rectangle" + }, + "ToArea": { + "title": "Destination area", + "description": "The part of the target image to replace. Defaults to the FromArea size starting from the top-left corner.", + "$ref": "#/definitions/Rectangle" + }, + "PatchMode": { + "title": "Patch mode", + "description": "How to apply FromArea to ToArea. Defaults to Replace.", + "type": "string", + "enum": [ "Replace", "Overlay" ], + "default": "Replace" + }, + "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.", + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "Entries": { + "title": "Entries", + "description": "The entries in the data file you want to add, replace, or delete. If you only want to change a few fields, use Fields instead for best compatibility with other mods. To add an entry, just specify a key that doesn't exist; to delete an entry, set the value to null (like \"some key\": null). This field supports tokens in entry keys and values.\nCaution: some XNB files have extra fields at the end for translations; when adding or replacing an entry for all locales, make sure you include the extra fields to avoid errors for non-English players.", + "type": "object", + "additionalProperties": { + "type": [ "object", "string" ] + } + }, + "MoveEntries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "ID": { + "title": "ID", + "description": "The ID of the entry to move", + "type": "string" + }, + "BeforeID": { + "title": "Before ID", + "description": "Move entry so it's right before this ID", + "type": "string" + }, + "AfterID": { + "title": "After ID", + "description": "Move entry so it's right after this ID", + "type": "string" + }, + "ToPosition": { + "title": "To position", + "description": "Move entry so it's right at this position", + "enum": [ "Top", "Bottom" ] + } + }, + + "anyOf": [ + { + "required": [ "BeforeID" ] + }, + { + "required": [ "AfterID" ] + }, + { + "required": [ "ToPosition" ] + } + ], + + "dependencies": { + "BeforeID": { + "propertyNames": { + "enum": [ "ID", "BeforeID" ] + } + }, + "AfterID": { + "propertyNames": { + "enum": [ "ID", "AfterID" ] + } + }, + "ToPosition": { + "propertyNames": { + "enum": [ "ID", "ToPosition" ] + } + } + }, + + "required": [ "ID" ], + "@errorMessages": { + "anyOf": "You must specify one of 'AfterID', 'BeforeID', or 'ToPosition'.", + "dependencies:BeforeID": "If 'BeforeID' is specified, only 'ID' and 'BeforeID' fields are valid.", + "dependencies:AfterID": "If 'AfterID' is specified, only 'ID' and 'AfterID' fields are valid.", + "dependencies:ToPosition": "If 'ToPosition' is specified, only 'ID' and 'ToPosition' fields are valid." + } + } + }, + "When": { + "title": "When", + "description": "Only apply the patch if the given conditions match.", + "$ref": "#/definitions/Condition" + } + }, + "allOf": [ + { + "if": { + "properties": { + "Action": { "const": "Load" } + } + }, + "then": { + "required": [ "FromFile" ], + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditImage" } + } + }, + "then": { + "required": [ "FromFile" ], + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "PatchMode" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditData" } + } + }, + "then": { + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "Fields", "Entries", "MoveEntries" ] + } + } + }, + { + "if": { + "properties": { + "Action": { "const": "EditMap" } + } + }, + "then": { + "properties": { + "FromFile": { + "description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you if it's a .tbin file:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder." + }, + "FromArea": { + "description": "The part of the source map to copy. Defaults to the whole source map." + }, + "ToArea": { + "description": "The part of the target map to replace." + } + }, + "propertyNames": { + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ] + }, + "required": [ "FromFile", "ToArea" ] + } + } + ], + + "required": [ "Action", "Target" ], + "@errorMessages": { + "allOf": "$transparent" + } + } + }, + "$schema": { + "title": "Schema", + "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", + "type": "string", + "const": "https://smapi.io/schemas/content-patcher.json" + } + }, + "definitions": { + "Condition": { + "type": "object", + "additionalProperties": { + "type": [ "boolean", "string" ] + } + }, + "Rectangle": { + "type": "object", + "properties": { + "X": { + "title": "X-Coordinate", + "description": "Location in pixels of the top-left of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Y": { + "title": "Y-Coordinate", + "description": "Location in pixels of the top-left of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Width": { + "title": "Width", + "description": "The width of the rectangle", + "type": "integer", + "minimum:": 0 + }, + "Height": { + "title": "Height", + "description": "The height of the rectangle", + "type": "integer", + "minimum:": 0 + } + }, + + "required": [ "X", "Y", "Width", "Height" ], + "additionalProperties": false + } + }, + + "required": [ "Format", "Changes" ], + "additionalProperties": false +} diff --git a/src/SMAPI.Web/wwwroot/schemas/manifest.json b/src/SMAPI.Web/wwwroot/schemas/manifest.json new file mode 100644 index 00000000..685b515b --- /dev/null +++ b/src/SMAPI.Web/wwwroot/schemas/manifest.json @@ -0,0 +1,147 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://smapi.io/schemas/manifest.json", + "title": "SMAPI manifest", + "description": "Manifest file for a SMAPI mod or content pack", + "@documentationUrl": "https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest", + "type": "object", + "properties": { + "Name": { + "title": "Mod name", + "description": "The mod's display name. SMAPI uses this in player messages, logs, and errors.", + "type": "string", + "examples": [ "Lookup Anything" ] + }, + "Author": { + "title": "Mod author", + "description": "The name of the person who created the mod. Ideally this should include the username used to publish mods.", + "type": "string", + "examples": [ "Pathoschild" ] + }, + "Version": { + "title": "Mod version", + "description": "The mod's semantic version. Make sure you update this for each release! SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game).", + "$ref": "#/definitions/SemanticVersion" + }, + "Description": { + "title": "Mod description", + "description": "A short explanation of what your mod does (one or two sentences), shown in the SMAPI log.", + "type": "string", + "examples": [ "View metadata about anything by pressing a button." ] + }, + "UniqueID": { + "title": "Mod unique ID", + "description": "A unique identifier for your mod. The recommended format is \"Username.ModName\", with no spaces or special characters. SMAPI uses this for update checks, mod dependencies, and compatibility blacklists (if the mod breaks in a future version of the game). When another mod needs to reference this mod, it uses the unique ID.", + "$ref": "#/definitions/ModID" + }, + "EntryDll": { + "title": "Mod entry DLL", + "description": "The DLL filename SMAPI should load for this mod. Mutually exclusive with ContentPackFor.", + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+\\.dll$", + "examples": "LookupAnything.dll", + "@errorMessages": { + "pattern": "Invalid value; must be a filename ending with .dll." + } + }, + "ContentPackFor": { + "title": "Content pack for", + "description": "Specifies the mod which can read this content pack.", + "type": "object", + "properties": { + "UniqueID": { + "title": "Required unique ID", + "description": "The unique ID of the mod which can read this content pack.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Required minimum version", + "description": "The minimum semantic version of the mod which can read this content pack, if applicable.", + "$ref": "#/definitions/SemanticVersion" + } + }, + + "required": [ "UniqueID" ] + }, + "MinimumApiVersion": { + "title": "Minimum API version", + "description": "The minimum SMAPI version needed to use this mod. If a player tries to use the mod with an older SMAPI version, they'll see a friendly message saying they need to update SMAPI. This also serves as a proxy for the minimum game version, since SMAPI itself enforces a minimum game version.", + "$ref": "#/definitions/SemanticVersion" + }, + "Dependencies": { + "title": "Mod dependencies", + "description": "Specifies other mods to load before this mod. If a dependency is required and a player tries to use the mod without the dependency installed, the mod won't be loaded and they'll see a friendly message saying they need to install those.", + "type": "array", + "items": { + "type": "object", + "properties": { + "UniqueID": { + "title": "Dependency unique ID", + "description": "The unique ID of the mod to load first.", + "$ref": "#/definitions/ModID" + }, + "MinimumVersion": { + "title": "Dependency minimum version", + "description": "The minimum semantic version of the mod to load first, if applicable.", + "$ref": "#/definitions/SemanticVersion" + }, + "IsRequired": { + "title": "Dependency is required", + "description": "Whether the dependency is required. Default true if not specified." + } + }, + "required": [ "UniqueID" ] + } + }, + "UpdateKeys": { + "title": "Mod update keys", + "description": "Specifies where SMAPI should check for mod updates, so it can alert the user with a link to your mod page. See https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info.", + "type": "array", + "items": { + "type": "string", + "pattern": "^(Chucklefish:\\d+|Nexus:\\d+|GitHub:[A-Za-z0-9_]+/[A-Za-z0-9_]+|ModDrop:\\d+)$", + "@errorMessages": { + "pattern": "Invalid update key; see https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks for more info." + } + } + }, + "$schema": { + "title": "Schema", + "description": "A reference to this JSON schema. Not part of the actual format, but useful for validation tools.", + "type": "string", + "const": "https://smapi.io/schemas/manifest.json" + } + }, + "definitions": { + "SemanticVersion": { + "type": "string", + "pattern": "^(?>(?:0|[1-9]\\d*))\\.(?>(?:0|[1-9]\\d*))(?>(?:\\.(?:0|[1-9]\\d*))?)(?:-(?:(?>[a-zA-Z0-9]+[\\-\\.]?)+))?$", + "$comment": "derived from SMAPI.Toolkit.SemanticVersion", + "examples": [ "1.0.0", "1.0.1-beta.2" ], + "@errorMessages": { + "pattern": "Invalid semantic version; must be formatted like 1.2.0 or 1.2.0-prerelease.tags. See https://semver.org/ for more info." + } + }, + "ModID": { + "type": "string", + "pattern": "^[a-zA-Z0-9_.-]+$", + "$comment": "derived from SMAPI.Toolkit.Utilities.PathUtilities.IsSlug", + "examples": [ "Pathoschild.LookupAnything" ] + } + }, + + "required": [ "Name", "Author", "Version", "Description", "UniqueID" ], + "oneOf": [ + { + "required": [ "EntryDll" ] + }, + { + "required": [ "ContentPackFor" ] + } + ], + "additionalProperties": false, + "@errorMessages": { + "oneOf:valid against no schemas": "Missing required field: EntryDll or ContentPackFor.", + "oneOf:valid against more than one schema": "Can't specify both EntryDll and ContentPackFor, they're mutually exclusive." + } +} diff --git a/src/SMAPI.sln b/src/SMAPI.sln index ffd50455..73852d30 100644 --- a/src/SMAPI.sln +++ b/src/SMAPI.sln @@ -3,8 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.28729.10 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI", "SMAPI\StardewModdingAPI.csproj", "{1298F2B2-57BD-4647-AF70-1FCBBEE500B6}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{86C452BE-D2D8-45B4-B63F-E329EB06CEDA}" ProjectSection(SolutionItems) = preProject ..\.editorconfig = ..\.editorconfig @@ -13,63 +11,72 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{86C452BE ..\LICENSE.txt = ..\LICENSE.txt EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Installer", "SMAPI.Installer\StardewModdingAPI.Installer.csproj", "{0ED5EAD8-5D85-420D-8101-6D8CCCE29C9B}" - ProjectSection(ProjectDependencies) = postProject - {E4113F3E-3CAA-4288-9378-39A77BA625DB} = {E4113F3E-3CAA-4288-9378-39A77BA625DB} - {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7} = {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7} - {1298F2B2-57BD-4647-AF70-1FCBBEE500B6} = {1298F2B2-57BD-4647-AF70-1FCBBEE500B6} +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{4B1CEB70-F756-4A57-AAE8-8CD78C475F25}" + ProjectSection(SolutionItems) = preProject + ..\.github\CONTRIBUTING.md = ..\.github\CONTRIBUTING.md + ..\.github\SUPPORT.md = ..\.github\SUPPORT.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Tests", "SMAPI.Tests\StardewModdingAPI.Tests.csproj", "{E023DA12-5960-4101-80B9-A7DCE955725C}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Web", "SMAPI.Web\StardewModdingAPI.Web.csproj", "{A308F679-51A3-4006-92D5-BAEC7EBD01A1}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{F4453AB6-D7D6-447F-A973-956CC777968F}" + ProjectSection(SolutionItems) = preProject + ..\.github\ISSUE_TEMPLATE\bug_report.md = ..\.github\ISSUE_TEMPLATE\bug_report.md + ..\.github\ISSUE_TEMPLATE\feature_request.md = ..\.github\ISSUE_TEMPLATE\feature_request.md + ..\.github\ISSUE_TEMPLATE\general.md = ..\.github\ISSUE_TEMPLATE\general.md + EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5-5BAB-4650-A200-E5EA9A633046}" + ProjectSection(SolutionItems) = preProject + ..\build\common.targets = ..\build\common.targets + ..\build\find-game-folder.targets = ..\build\find-game-folder.targets + ..\build\GlobalAssemblyInfo.cs = ..\build\GlobalAssemblyInfo.cs + ..\build\prepare-install-package.targets = ..\build\prepare-install-package.targets + ..\build\prepare-nuget-package.targets = ..\build\prepare-nuget-package.targets + EndProjectSection EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB}" ProjectSection(SolutionItems) = preProject ..\docs\mod-build-config.md = ..\docs\mod-build-config.md ..\docs\README.md = ..\docs\README.md ..\docs\release-notes.md = ..\docs\release-notes.md - ..\docs\technical-docs.md = ..\docs\technical-docs.md EndProjectSection EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5-5BAB-4650-A200-E5EA9A633046}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "technical", "technical", "{5947303D-3512-413A-9009-7AC43F5D3513}" ProjectSection(SolutionItems) = preProject - ..\build\common.targets = ..\build\common.targets - ..\build\GlobalAssemblyInfo.cs = ..\build\GlobalAssemblyInfo.cs - ..\build\prepare-install-package.targets = ..\build\prepare-install-package.targets - ..\build\prepare-nuget-package.targets = ..\build\prepare-nuget-package.targets + ..\docs\technical\mod-package.md = ..\docs\technical\mod-package.md + ..\docs\technical\smapi.md = ..\docs\technical\smapi.md + ..\docs\technical\web.md = ..\docs\technical\web.md EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.ModBuildConfig", "SMAPI.ModBuildConfig\StardewModdingAPI.ModBuildConfig.csproj", "{C11D0AFB-2893-41A9-AD55-D002F032D6AD}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Internal", "Internal", "{82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.ModBuildConfig.Analyzer", "SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj", "{80AD8528-AA49-4731-B4A6-C691845815A1}" +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "SMAPI.Internal", "SMAPI.Internal\SMAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{680B2641-81EA-467C-86A5-0E81CDC57ED0}" EndProject -Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "StardewModdingAPI.Internal", "SMAPI.Internal\StardewModdingAPI.Internal.shproj", "{85208F8D-6FD1-4531-BE05-7142490F59FE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Tests", "SMAPI.Tests\SMAPI.Tests.csproj", "{AA95884B-7097-476E-92C8-D0500DE9D6D1}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\StardewModdingAPI.Mods.ConsoleCommands.csproj", "{8C2CA4AB-BA8A-446A-B59E-9D6502E145F7}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI", "SMAPI\SMAPI.csproj", "{E6DA2198-7686-4F1D-B312-4A4DC70884C0}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Mods.SaveBackup", "SMAPI.Mods.SaveBackup\StardewModdingAPI.Mods.SaveBackup.csproj", "{E4113F3E-3CAA-4288-9378-39A77BA625DB}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Installer", "SMAPI.Installer\SMAPI.Installer.csproj", "{0A9BB24F-15FF-4C26-B1A2-81F7AE316518}" + ProjectSection(ProjectDependencies) = postProject + {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} = {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F} + {CD53AD6F-97F4-4872-A212-50C2A0FD3601} = {CD53AD6F-97F4-4872-A212-50C2A0FD3601} + {E6DA2198-7686-4F1D-B312-4A4DC70884C0} = {E6DA2198-7686-4F1D-B312-4A4DC70884C0} + EndProjectSection EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit", "SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj", "{EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig", "SMAPI.ModBuildConfig\SMAPI.ModBuildConfig.csproj", "{1B3821E6-D030-402C-B3A1-7CA45C2800EA}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.Toolkit.CoreInterfaces", "SMAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj", "{D5CFD923-37F1-4BC3-9BE8-E506E202AC28}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer", "SMAPI.ModBuildConfig.Analyzer\SMAPI.ModBuildConfig.Analyzer.csproj", "{517677D7-7299-426F-B1A3-47BDCC2F1214}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".github", ".github", "{4B1CEB70-F756-4A57-AAE8-8CD78C475F25}" - ProjectSection(SolutionItems) = preProject - ..\.github\CONTRIBUTING.md = ..\.github\CONTRIBUTING.md - ..\.github\SUPPORT.md = ..\.github\SUPPORT.md - EndProjectSection +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.ConsoleCommands", "SMAPI.Mods.ConsoleCommands\SMAPI.Mods.ConsoleCommands.csproj", "{0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ISSUE_TEMPLATE", "ISSUE_TEMPLATE", "{F4453AB6-D7D6-447F-A973-956CC777968F}" - ProjectSection(SolutionItems) = preProject - ..\.github\ISSUE_TEMPLATE\bug_report.md = ..\.github\ISSUE_TEMPLATE\bug_report.md - ..\.github\ISSUE_TEMPLATE\feature_request.md = ..\.github\ISSUE_TEMPLATE\feature_request.md - ..\.github\ISSUE_TEMPLATE\general.md = ..\.github\ISSUE_TEMPLATE\general.md - EndProjectSection +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Mods.SaveBackup", "SMAPI.Mods.SaveBackup\SMAPI.Mods.SaveBackup.csproj", "{CD53AD6F-97F4-4872-A212-50C2A0FD3601}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit", "SMAPI.Toolkit\SMAPI.Toolkit.csproj", "{08184F74-60AD-4EEE-A78C-F4A35ADE6246}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Toolkit.CoreInterfaces", "SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj", "{ED8E41FA-DDFA-4A77-932E-9853D279A129}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.Web", "SMAPI.Web\SMAPI.Web.csproj", "{80EFD92F-728F-41E0-8A5B-9F6F49A91899}" EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution @@ -80,61 +87,63 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {1298F2B2-57BD-4647-AF70-1FCBBEE500B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {1298F2B2-57BD-4647-AF70-1FCBBEE500B6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {1298F2B2-57BD-4647-AF70-1FCBBEE500B6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {1298F2B2-57BD-4647-AF70-1FCBBEE500B6}.Release|Any CPU.Build.0 = Release|Any CPU - {0ED5EAD8-5D85-420D-8101-6D8CCCE29C9B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0ED5EAD8-5D85-420D-8101-6D8CCCE29C9B}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0ED5EAD8-5D85-420D-8101-6D8CCCE29C9B}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0ED5EAD8-5D85-420D-8101-6D8CCCE29C9B}.Release|Any CPU.Build.0 = Release|Any CPU - {E023DA12-5960-4101-80B9-A7DCE955725C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E023DA12-5960-4101-80B9-A7DCE955725C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E023DA12-5960-4101-80B9-A7DCE955725C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E023DA12-5960-4101-80B9-A7DCE955725C}.Release|Any CPU.Build.0 = Release|Any CPU - {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {A308F679-51A3-4006-92D5-BAEC7EBD01A1}.Release|Any CPU.Build.0 = Release|Any CPU - {C11D0AFB-2893-41A9-AD55-D002F032D6AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C11D0AFB-2893-41A9-AD55-D002F032D6AD}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C11D0AFB-2893-41A9-AD55-D002F032D6AD}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C11D0AFB-2893-41A9-AD55-D002F032D6AD}.Release|Any CPU.Build.0 = Release|Any CPU - {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.Build.0 = Release|Any CPU - {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.Build.0 = Debug|Any CPU - {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.ActiveCfg = Release|Any CPU - {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.Build.0 = Release|Any CPU - {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Debug|Any CPU.Build.0 = Debug|Any CPU - {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Release|Any CPU.ActiveCfg = Release|Any CPU - {EA5CFD2E-9453-4D29-B80F-8E0EA23F4AC6}.Release|Any CPU.Build.0 = Release|Any CPU - {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D5CFD923-37F1-4BC3-9BE8-E506E202AC28}.Release|Any CPU.Build.0 = Release|Any CPU - {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8C2CA4AB-BA8A-446A-B59E-9D6502E145F7}.Release|Any CPU.Build.0 = Release|Any CPU - {E4113F3E-3CAA-4288-9378-39A77BA625DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E4113F3E-3CAA-4288-9378-39A77BA625DB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E4113F3E-3CAA-4288-9378-39A77BA625DB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E4113F3E-3CAA-4288-9378-39A77BA625DB}.Release|Any CPU.Build.0 = Release|Any CPU + {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {680B2641-81EA-467C-86A5-0E81CDC57ED0}.Release|Any CPU.Build.0 = Release|Any CPU + {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA95884B-7097-476E-92C8-D0500DE9D6D1}.Release|Any CPU.Build.0 = Release|Any CPU + {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6DA2198-7686-4F1D-B312-4A4DC70884C0}.Release|Any CPU.Build.0 = Release|Any CPU + {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0A9BB24F-15FF-4C26-B1A2-81F7AE316518}.Release|Any CPU.Build.0 = Release|Any CPU + {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B3821E6-D030-402C-B3A1-7CA45C2800EA}.Release|Any CPU.Build.0 = Release|Any CPU + {517677D7-7299-426F-B1A3-47BDCC2F1214}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {517677D7-7299-426F-B1A3-47BDCC2F1214}.Debug|Any CPU.Build.0 = Debug|Any CPU + {517677D7-7299-426F-B1A3-47BDCC2F1214}.Release|Any CPU.ActiveCfg = Release|Any CPU + {517677D7-7299-426F-B1A3-47BDCC2F1214}.Release|Any CPU.Build.0 = Release|Any CPU + {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0634EA4C-3B8F-42DB-AEA6-CA9E4EF6E92F}.Release|Any CPU.Build.0 = Release|Any CPU + {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD53AD6F-97F4-4872-A212-50C2A0FD3601}.Release|Any CPU.Build.0 = Release|Any CPU + {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08184F74-60AD-4EEE-A78C-F4A35ADE6246}.Release|Any CPU.Build.0 = Release|Any CPU + {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ED8E41FA-DDFA-4A77-932E-9853D279A129}.Release|Any CPU.Build.0 = Release|Any CPU + {80EFD92F-728F-41E0-8A5B-9F6F49A91899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {E023DA12-5960-4101-80B9-A7DCE955725C} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} - {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} - {09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} - {0CF97929-B0D0-4D73-B7BF-4FF7191035F9} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} {F4453AB6-D7D6-447F-A973-956CC777968F} = {4B1CEB70-F756-4A57-AAE8-8CD78C475F25} + {09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} + {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA} + {5947303D-3512-413A-9009-7AC43F5D3513} = {EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} + {85208F8D-6FD1-4531-BE05-7142490F59FE} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {680B2641-81EA-467C-86A5-0E81CDC57ED0} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11} + {AA95884B-7097-476E-92C8-D0500DE9D6D1} = {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 5f67fd9e..556f1ec0 100644 --- a/src/SMAPI.sln.DotSettings +++ b/src/SMAPI.sln.DotSettings @@ -23,4 +23,46 @@ <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002ECSharpPlaceAttributeOnSameLineMigration/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateBlankLinesAroundFieldToBlankLinesAroundProperty/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EPsi_002ECSharp_002ECodeStyle_002ESettingsUpgrade_002EMigrateThisQualifierSettings/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=analytics/@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/=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/=decoratable/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=devs/@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> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Hangfire/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=initializers/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Junimo/@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> + <s:Boolean x:Key="/Default/UserDictionary/Words/=multiplayer/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Netcode/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=overworld/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Pastebin/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=Pathoschild/@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/=prerelease/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=pufferchick/@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> + <s:Boolean x:Key="/Default/UserDictionary/Words/=spawnable/@EntryIndexedValue">True</s:Boolean> + <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/=subdomain/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=synchronised/@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/=unloadable/@EntryIndexedValue">True</s:Boolean> + <s:Boolean x:Key="/Default/UserDictionary/Words/=virally/@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 9d686e2f..9b113733 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -5,7 +5,7 @@ using System.Reflection; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework; using StardewModdingAPI.Framework.ModLoading; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; namespace StardewModdingAPI @@ -20,13 +20,13 @@ namespace StardewModdingAPI ** Public ****/ /// <summary>SMAPI's current semantic version.</summary> - public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("2.11.3"); + public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.0"); /// <summary>The minimum supported version of Stardew Valley.</summary> - public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.3.36"); + public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0"); /// <summary>The maximum supported version of Stardew Valley.</summary> - public static ISemanticVersion MaximumGameVersion { get; } = new GameVersion("1.3.36"); + public static ISemanticVersion MaximumGameVersion { get; } = null; /// <summary>The target game platform.</summary> public static GamePlatform TargetPlatform => (GamePlatform)Constants.Platform; @@ -44,32 +44,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 - { - get - { - return Constants.GetSaveFolderName() -#if SMAPI_3_0_STRICT - ; -#else - ?? ""; -#endif - } - } + 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 - { - get - { - return Constants.GetSaveFolderPathIfExists() -#if SMAPI_3_0_STRICT - ; -#else - ?? ""; -#endif - } - } + public static string CurrentSavePath => Constants.GetSaveFolderPathIfExists(); /**** ** Internal @@ -81,10 +59,10 @@ namespace StardewModdingAPI internal static readonly string InternalFilesPath = Program.DllSearchPath; /// <summary>The file path for the SMAPI configuration file.</summary> - internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.config.json"); + internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json"); /// <summary>The file path for the SMAPI metadata file.</summary> - internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "StardewModdingAPI.metadata.json"); + internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json"); /// <summary>The filename prefix used for all SMAPI logs.</summary> internal static string LogNamePrefix { get; } = "SMAPI-"; @@ -119,6 +97,9 @@ namespace StardewModdingAPI /// <summary>The game's assembly name.</summary> internal static string GameAssemblyName => Constants.Platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; + /// <summary>The language code for non-translated mod assets.</summary> + internal static LocalizedContentManager.LanguageCode DefaultLanguage { get; } = LocalizedContentManager.LanguageCode.en; + /********* ** Internal methods @@ -130,6 +111,13 @@ namespace StardewModdingAPI { switch (version.ToString()) { + case "1.3.36": + return new SemanticVersion(2, 11, 2); + + case "1.3.32": + case "1.3.33": + return new SemanticVersion(2, 10, 2); + case "1.3.28": return new SemanticVersion(2, 7, 0); @@ -161,12 +149,14 @@ namespace StardewModdingAPI "Microsoft.Xna.Framework", "Microsoft.Xna.Framework.Game", "Microsoft.Xna.Framework.Graphics", - "Microsoft.Xna.Framework.Xact" + "Microsoft.Xna.Framework.Xact", + "StardewModdingAPI.Toolkit.CoreInterfaces" // renamed in SMAPI 3.0 }; targetAssemblies = new[] { typeof(StardewValley.Game1).Assembly, // note: includes Netcode types on Linux/Mac - typeof(Microsoft.Xna.Framework.Vector2).Assembly + typeof(Microsoft.Xna.Framework.Vector2).Assembly, + typeof(StardewModdingAPI.IManifest).Assembly }; break; @@ -174,7 +164,8 @@ namespace StardewModdingAPI removeAssemblyReferences = new[] { "StardewValley", - "MonoGame.Framework" + "MonoGame.Framework", + "StardewModdingAPI.Toolkit.CoreInterfaces" // renamed in SMAPI 3.0 }; targetAssemblies = new[] { @@ -182,7 +173,8 @@ namespace StardewModdingAPI typeof(StardewValley.Game1).Assembly, typeof(Microsoft.Xna.Framework.Vector2).Assembly, typeof(Microsoft.Xna.Framework.Game).Assembly, - typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch).Assembly + typeof(Microsoft.Xna.Framework.Graphics.SpriteBatch).Assembly, + typeof(StardewModdingAPI.IManifest).Assembly }; break; @@ -198,7 +190,7 @@ namespace StardewModdingAPI ** Private methods *********/ /// <summary>Get the name of the save folder, if any.</summary> - internal static string GetSaveFolderName() + private static string GetSaveFolderName() { // save not available if (Context.LoadStage == LoadStage.None) @@ -223,7 +215,7 @@ namespace StardewModdingAPI } /// <summary>Get the path to the current save folder, if any.</summary> - internal static string GetSaveFolderPathIfExists() + private static string GetSaveFolderPathIfExists() { string folderName = Constants.GetSaveFolderName(); if (folderName == null) diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs index 1cdef7f1..a7238b32 100644 --- a/src/SMAPI/Context.cs +++ b/src/SMAPI/Context.cs @@ -14,7 +14,10 @@ namespace StardewModdingAPI /**** ** Public ****/ - /// <summary>Whether the player has loaded a save and the world has finished initialising.</summary> + /// <summary>Whether the game has performed core initialization. This becomes true right before the first update tick.</summary> + public static bool IsGameLaunched { get; internal set; } + + /// <summary>Whether the player has loaded a save and the world has finished initializing.</summary> public static bool IsWorldReady { get; internal set; } /// <summary>Whether <see cref="IsWorldReady"/> is true and the player is free to act in the world (no menu is displayed, no cutscene is in progress, etc).</summary> diff --git a/src/SMAPI/Enums/LoadStage.cs b/src/SMAPI/Enums/LoadStage.cs index 6ff7de4f..5c2b0412 100644 --- a/src/SMAPI/Enums/LoadStage.cs +++ b/src/SMAPI/Enums/LoadStage.cs @@ -6,10 +6,10 @@ namespace StardewModdingAPI.Enums /// <summary>A save is not loaded or loading.</summary> None, - /// <summary>The game is creating a new save slot, and has initialised the basic save info.</summary> + /// <summary>The game is creating a new save slot, and has initialized the basic save info.</summary> CreatedBasicInfo, - /// <summary>The game is creating a new save slot, and has initialised the in-game locations.</summary> + /// <summary>The game is creating a new save slot, and has initialized the in-game locations.</summary> CreatedLocations, /// <summary>The game is creating a new save slot, and has created the physical save files.</summary> @@ -18,7 +18,7 @@ namespace StardewModdingAPI.Enums /// <summary>The game is loading a save slot, and has read the raw save data into <see cref="StardewValley.SaveGame.loaded"/>. Not applicable when connecting to a multiplayer host. This is equivalent to <see cref="StardewValley.SaveGame.getLoadEnumerator"/> value 20.</summary> SaveParsed, - /// <summary>The game is loading a save slot, and has applied the basic save info (including player data). Not applicable when connecting to a multiplayer host. Note that some basic info (like daily luck) is not initialised at this point. This is equivalent to <see cref="StardewValley.SaveGame.getLoadEnumerator"/> value 36.</summary> + /// <summary>The game is loading a save slot, and has applied the basic save info (including player data). Not applicable when connecting to a multiplayer host. Note that some basic info (like daily luck) is not initialized at this point. This is equivalent to <see cref="StardewValley.SaveGame.getLoadEnumerator"/> value 36.</summary> SaveLoadedBasicInfo, /// <summary>The game is loading a save slot, and has applied the in-game location data. Not applicable when connecting to a multiplayer host. This is equivalent to <see cref="StardewValley.SaveGame.getLoadEnumerator"/> value 50.</summary> @@ -27,10 +27,10 @@ namespace StardewModdingAPI.Enums /// <summary>The final metadata has been loaded from the save file. This happens before the game applies problem fixes, checks for achievements, starts music, etc. Not applicable when connecting to a multiplayer host.</summary> Preloaded, - /// <summary>The save is fully loaded, but the world may not be fully initialised yet.</summary> + /// <summary>The save is fully loaded, but the world may not be fully initialized yet.</summary> Loaded, - /// <summary>The save is fully loaded, the world has been initialised, and <see cref="Context.IsWorldReady"/> is now true.</summary> + /// <summary>The save is fully loaded, the world has been initialized, and <see cref="Context.IsWorldReady"/> is now true.</summary> Ready } } diff --git a/src/SMAPI/Events/ContentEvents.cs b/src/SMAPI/Events/ContentEvents.cs deleted file mode 100644 index aca76ef7..00000000 --- a/src/SMAPI/Events/ContentEvents.cs +++ /dev/null @@ -1,45 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the game loads content.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class ContentEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after the content language changes.</summary> - public static event EventHandler<EventArgsValueChanged<string>> AfterLocaleChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ContentEvents.EventManager.Legacy_LocaleChanged.Add(value); - } - remove => ContentEvents.EventManager.Legacy_LocaleChanged.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - ContentEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/ControlEvents.cs b/src/SMAPI/Events/ControlEvents.cs deleted file mode 100644 index 45aedc9b..00000000 --- a/src/SMAPI/Events/ControlEvents.cs +++ /dev/null @@ -1,123 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework.Input; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the player uses a controller, keyboard, or mouse.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class ControlEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> - public static event EventHandler<EventArgsKeyboardStateChanged> KeyboardChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_KeyboardChanged.Add(value); - } - remove => ControlEvents.EventManager.Legacy_KeyboardChanged.Remove(value); - } - - /// <summary>Raised after the player presses a keyboard key.</summary> - public static event EventHandler<EventArgsKeyPressed> KeyPressed - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_KeyPressed.Add(value); - } - remove => ControlEvents.EventManager.Legacy_KeyPressed.Remove(value); - } - - /// <summary>Raised after the player releases a keyboard key.</summary> - public static event EventHandler<EventArgsKeyPressed> KeyReleased - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_KeyReleased.Add(value); - } - remove => ControlEvents.EventManager.Legacy_KeyReleased.Remove(value); - } - - /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> - public static event EventHandler<EventArgsMouseStateChanged> MouseChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_MouseChanged.Add(value); - } - remove => ControlEvents.EventManager.Legacy_MouseChanged.Remove(value); - } - - /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> - public static event EventHandler<EventArgsControllerButtonPressed> ControllerButtonPressed - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_ControllerButtonPressed.Add(value); - } - remove => ControlEvents.EventManager.Legacy_ControllerButtonPressed.Remove(value); - } - - /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> - public static event EventHandler<EventArgsControllerButtonReleased> ControllerButtonReleased - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_ControllerButtonReleased.Add(value); - } - remove => ControlEvents.EventManager.Legacy_ControllerButtonReleased.Remove(value); - } - - /// <summary>The player pressed a controller trigger button.</summary> - public static event EventHandler<EventArgsControllerTriggerPressed> ControllerTriggerPressed - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Add(value); - } - remove => ControlEvents.EventManager.Legacy_ControllerTriggerPressed.Remove(value); - } - - /// <summary>The player released a controller trigger button.</summary> - public static event EventHandler<EventArgsControllerTriggerReleased> ControllerTriggerReleased - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Add(value); - } - remove => ControlEvents.EventManager.Legacy_ControllerTriggerReleased.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - ControlEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsClickableMenuChanged.cs b/src/SMAPI/Events/EventArgsClickableMenuChanged.cs deleted file mode 100644 index a0b903b7..00000000 --- a/src/SMAPI/Events/EventArgsClickableMenuChanged.cs +++ /dev/null @@ -1,33 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewValley.Menus; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="MenuEvents.MenuChanged"/> event.</summary> - public class EventArgsClickableMenuChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous menu.</summary> - public IClickableMenu NewMenu { get; } - - /// <summary>The current menu.</summary> - public IClickableMenu PriorMenu { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorMenu">The previous menu.</param> - /// <param name="newMenu">The current menu.</param> - public EventArgsClickableMenuChanged(IClickableMenu priorMenu, IClickableMenu newMenu) - { - this.NewMenu = newMenu; - this.PriorMenu = priorMenu; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsClickableMenuClosed.cs b/src/SMAPI/Events/EventArgsClickableMenuClosed.cs deleted file mode 100644 index 77db69ea..00000000 --- a/src/SMAPI/Events/EventArgsClickableMenuClosed.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewValley.Menus; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="MenuEvents.MenuClosed"/> event.</summary> - public class EventArgsClickableMenuClosed : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The menu that was closed.</summary> - public IClickableMenu PriorMenu { get; } - - - /********* - ** Accessors - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorMenu">The menu that was closed.</param> - public EventArgsClickableMenuClosed(IClickableMenu priorMenu) - { - this.PriorMenu = priorMenu; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsControllerButtonPressed.cs b/src/SMAPI/Events/EventArgsControllerButtonPressed.cs deleted file mode 100644 index 949446e1..00000000 --- a/src/SMAPI/Events/EventArgsControllerButtonPressed.cs +++ /dev/null @@ -1,34 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonPressed"/> event.</summary> - public class EventArgsControllerButtonPressed : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player who pressed the button.</summary> - public PlayerIndex PlayerIndex { get; } - - /// <summary>The controller button that was pressed.</summary> - public Buttons ButtonPressed { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="playerIndex">The player who pressed the button.</param> - /// <param name="button">The controller button that was pressed.</param> - public EventArgsControllerButtonPressed(PlayerIndex playerIndex, Buttons button) - { - this.PlayerIndex = playerIndex; - this.ButtonPressed = button; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsControllerButtonReleased.cs b/src/SMAPI/Events/EventArgsControllerButtonReleased.cs deleted file mode 100644 index d6d6d840..00000000 --- a/src/SMAPI/Events/EventArgsControllerButtonReleased.cs +++ /dev/null @@ -1,34 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.ControllerButtonReleased"/> event.</summary> - public class EventArgsControllerButtonReleased : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player who pressed the button.</summary> - public PlayerIndex PlayerIndex { get; } - - /// <summary>The controller button that was pressed.</summary> - public Buttons ButtonReleased { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="playerIndex">The player who pressed the button.</param> - /// <param name="button">The controller button that was released.</param> - public EventArgsControllerButtonReleased(PlayerIndex playerIndex, Buttons button) - { - this.PlayerIndex = playerIndex; - this.ButtonReleased = button; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsControllerTriggerPressed.cs b/src/SMAPI/Events/EventArgsControllerTriggerPressed.cs deleted file mode 100644 index 33be2fa3..00000000 --- a/src/SMAPI/Events/EventArgsControllerTriggerPressed.cs +++ /dev/null @@ -1,39 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerPressed"/> event.</summary> - public class EventArgsControllerTriggerPressed : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player who pressed the button.</summary> - public PlayerIndex PlayerIndex { get; } - - /// <summary>The controller button that was pressed.</summary> - public Buttons ButtonPressed { get; } - - /// <summary>The current trigger value.</summary> - public float Value { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="playerIndex">The player who pressed the trigger button.</param> - /// <param name="button">The trigger button that was pressed.</param> - /// <param name="value">The current trigger value.</param> - public EventArgsControllerTriggerPressed(PlayerIndex playerIndex, Buttons button, float value) - { - this.PlayerIndex = playerIndex; - this.ButtonPressed = button; - this.Value = value; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsControllerTriggerReleased.cs b/src/SMAPI/Events/EventArgsControllerTriggerReleased.cs deleted file mode 100644 index e90ff712..00000000 --- a/src/SMAPI/Events/EventArgsControllerTriggerReleased.cs +++ /dev/null @@ -1,39 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.ControllerTriggerReleased"/> event.</summary> - public class EventArgsControllerTriggerReleased : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player who pressed the button.</summary> - public PlayerIndex PlayerIndex { get; } - - /// <summary>The controller button that was released.</summary> - public Buttons ButtonReleased { get; } - - /// <summary>The current trigger value.</summary> - public float Value { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="playerIndex">The player who pressed the trigger button.</param> - /// <param name="button">The trigger button that was released.</param> - /// <param name="value">The current trigger value.</param> - public EventArgsControllerTriggerReleased(PlayerIndex playerIndex, Buttons button, float value) - { - this.PlayerIndex = playerIndex; - this.ButtonReleased = button; - this.Value = value; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsInput.cs b/src/SMAPI/Events/EventArgsInput.cs deleted file mode 100644 index 5cff3408..00000000 --- a/src/SMAPI/Events/EventArgsInput.cs +++ /dev/null @@ -1,64 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using System.Collections.Generic; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments when a button is pressed or released.</summary> - public class EventArgsInput : EventArgs - { - /********* - ** Fields - *********/ - /// <summary>The buttons to suppress.</summary> - private readonly HashSet<SButton> SuppressButtons; - - - /********* - ** Accessors - *********/ - /// <summary>The button on the controller, keyboard, or mouse.</summary> - public SButton Button { get; } - - /// <summary>The current cursor position.</summary> - public ICursorPosition Cursor { get; } - - /// <summary>Whether the input should trigger actions on the affected tile.</summary> - public bool IsActionButton => this.Button.IsActionButton(); - - /// <summary>Whether the input should use tools on the affected tile.</summary> - public bool IsUseToolButton => this.Button.IsUseToolButton(); - - /// <summary>Whether a mod has indicated the key was already handled.</summary> - public bool IsSuppressed => this.SuppressButtons.Contains(this.Button); - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="button">The button on the controller, keyboard, or mouse.</param> - /// <param name="cursor">The cursor position.</param> - /// <param name="suppressButtons">The buttons to suppress.</param> - public EventArgsInput(SButton button, ICursorPosition cursor, HashSet<SButton> suppressButtons) - { - this.Button = button; - this.Cursor = cursor; - this.SuppressButtons = suppressButtons; - } - - /// <summary>Prevent the game from handling the current button press. This doesn't prevent other mods from receiving the event.</summary> - public void SuppressButton() - { - this.SuppressButton(this.Button); - } - - /// <summary>Prevent the game from handling a button press. This doesn't prevent other mods from receiving the event.</summary> - /// <param name="button">The button to suppress.</param> - public void SuppressButton(SButton button) - { - this.SuppressButtons.Add(button); - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsIntChanged.cs b/src/SMAPI/Events/EventArgsIntChanged.cs deleted file mode 100644 index 76ec6d08..00000000 --- a/src/SMAPI/Events/EventArgsIntChanged.cs +++ /dev/null @@ -1,32 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for an integer field that changed value.</summary> - public class EventArgsIntChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous value.</summary> - public int PriorInt { get; } - - /// <summary>The current value.</summary> - public int NewInt { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorInt">The previous value.</param> - /// <param name="newInt">The current value.</param> - public EventArgsIntChanged(int priorInt, int newInt) - { - this.PriorInt = priorInt; - this.NewInt = newInt; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsInventoryChanged.cs b/src/SMAPI/Events/EventArgsInventoryChanged.cs deleted file mode 100644 index 488dd23f..00000000 --- a/src/SMAPI/Events/EventArgsInventoryChanged.cs +++ /dev/null @@ -1,43 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using System.Collections.Generic; -using System.Linq; -using StardewValley; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="PlayerEvents.InventoryChanged"/> event.</summary> - public class EventArgsInventoryChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player's inventory.</summary> - public IList<Item> Inventory { get; } - - /// <summary>The added items.</summary> - public List<ItemStackChange> Added { get; } - - /// <summary>The removed items.</summary> - public List<ItemStackChange> Removed { get; } - - /// <summary>The items whose stack sizes changed.</summary> - public List<ItemStackChange> QuantityChanged { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="inventory">The player's inventory.</param> - /// <param name="changedItems">The inventory changes.</param> - public EventArgsInventoryChanged(IList<Item> inventory, ItemStackChange[] changedItems) - { - this.Inventory = inventory; - this.Added = changedItems.Where(n => n.ChangeType == ChangeType.Added).ToList(); - this.Removed = changedItems.Where(n => n.ChangeType == ChangeType.Removed).ToList(); - this.QuantityChanged = changedItems.Where(n => n.ChangeType == ChangeType.StackChange).ToList(); - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsKeyPressed.cs b/src/SMAPI/Events/EventArgsKeyPressed.cs deleted file mode 100644 index 6204d821..00000000 --- a/src/SMAPI/Events/EventArgsKeyPressed.cs +++ /dev/null @@ -1,28 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> - public class EventArgsKeyPressed : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The keyboard button that was pressed.</summary> - public Keys KeyPressed { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="key">The keyboard button that was pressed.</param> - public EventArgsKeyPressed(Keys key) - { - this.KeyPressed = key; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsKeyboardStateChanged.cs b/src/SMAPI/Events/EventArgsKeyboardStateChanged.cs deleted file mode 100644 index 2c3203b1..00000000 --- a/src/SMAPI/Events/EventArgsKeyboardStateChanged.cs +++ /dev/null @@ -1,33 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.KeyboardChanged"/> event.</summary> - public class EventArgsKeyboardStateChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous keyboard state.</summary> - public KeyboardState NewState { get; } - - /// <summary>The current keyboard state.</summary> - public KeyboardState PriorState { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorState">The previous keyboard state.</param> - /// <param name="newState">The current keyboard state.</param> - public EventArgsKeyboardStateChanged(KeyboardState priorState, KeyboardState newState) - { - this.PriorState = priorState; - this.NewState = newState; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsLevelUp.cs b/src/SMAPI/Events/EventArgsLevelUp.cs deleted file mode 100644 index 06c70088..00000000 --- a/src/SMAPI/Events/EventArgsLevelUp.cs +++ /dev/null @@ -1,55 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Enums; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="PlayerEvents.LeveledUp"/> event.</summary> - public class EventArgsLevelUp : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player skill that leveled up.</summary> - public LevelType Type { get; } - - /// <summary>The new skill level.</summary> - public int NewLevel { get; } - - /// <summary>The player skill types.</summary> - public enum LevelType - { - /// <summary>The combat skill.</summary> - Combat = SkillType.Combat, - - /// <summary>The farming skill.</summary> - Farming = SkillType.Farming, - - /// <summary>The fishing skill.</summary> - Fishing = SkillType.Fishing, - - /// <summary>The foraging skill.</summary> - Foraging = SkillType.Foraging, - - /// <summary>The mining skill.</summary> - Mining = SkillType.Mining, - - /// <summary>The luck skill.</summary> - Luck = SkillType.Luck - } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="type">The player skill that leveled up.</param> - /// <param name="newLevel">The new skill level.</param> - public EventArgsLevelUp(LevelType type, int newLevel) - { - this.Type = type; - this.NewLevel = newLevel; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs b/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs deleted file mode 100644 index 25e84722..00000000 --- a/src/SMAPI/Events/EventArgsLocationBuildingsChanged.cs +++ /dev/null @@ -1,41 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using System.Collections.Generic; -using System.Linq; -using StardewValley; -using StardewValley.Buildings; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="LocationEvents.BuildingsChanged"/> event.</summary> - public class EventArgsLocationBuildingsChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The location which changed.</summary> - public GameLocation Location { get; } - - /// <summary>The buildings added to the location.</summary> - public IEnumerable<Building> Added { get; } - - /// <summary>The buildings removed from the location.</summary> - public IEnumerable<Building> Removed { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="location">The location which changed.</param> - /// <param name="added">The buildings added to the location.</param> - /// <param name="removed">The buildings removed from the location.</param> - public EventArgsLocationBuildingsChanged(GameLocation location, IEnumerable<Building> added, IEnumerable<Building> removed) - { - this.Location = location; - this.Added = added.ToArray(); - this.Removed = removed.ToArray(); - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs b/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs deleted file mode 100644 index 9ca2e3e2..00000000 --- a/src/SMAPI/Events/EventArgsLocationObjectsChanged.cs +++ /dev/null @@ -1,42 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Xna.Framework; -using StardewValley; -using SObject = StardewValley.Object; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="LocationEvents.ObjectsChanged"/> event.</summary> - public class EventArgsLocationObjectsChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The location which changed.</summary> - public GameLocation Location { get; } - - /// <summary>The objects added to the location.</summary> - public IEnumerable<KeyValuePair<Vector2, SObject>> Added { get; } - - /// <summary>The objects removed from the location.</summary> - public IEnumerable<KeyValuePair<Vector2, SObject>> Removed { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="location">The location which changed.</param> - /// <param name="added">The objects added to the location.</param> - /// <param name="removed">The objects removed from the location.</param> - public EventArgsLocationObjectsChanged(GameLocation location, IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed) - { - this.Location = location; - this.Added = added.ToArray(); - this.Removed = removed.ToArray(); - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsLocationsChanged.cs b/src/SMAPI/Events/EventArgsLocationsChanged.cs deleted file mode 100644 index 1a59e612..00000000 --- a/src/SMAPI/Events/EventArgsLocationsChanged.cs +++ /dev/null @@ -1,35 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using System.Collections.Generic; -using System.Linq; -using StardewValley; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="LocationEvents.LocationsChanged"/> event.</summary> - public class EventArgsLocationsChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The added locations.</summary> - public IEnumerable<GameLocation> Added { get; } - - /// <summary>The removed locations.</summary> - public IEnumerable<GameLocation> Removed { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="added">The added locations.</param> - /// <param name="removed">The removed locations.</param> - public EventArgsLocationsChanged(IEnumerable<GameLocation> added, IEnumerable<GameLocation> removed) - { - this.Added = added.ToArray(); - this.Removed = removed.ToArray(); - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsMineLevelChanged.cs b/src/SMAPI/Events/EventArgsMineLevelChanged.cs deleted file mode 100644 index c63b04e9..00000000 --- a/src/SMAPI/Events/EventArgsMineLevelChanged.cs +++ /dev/null @@ -1,32 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="MineEvents.MineLevelChanged"/> event.</summary> - public class EventArgsMineLevelChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous mine level.</summary> - public int PreviousMineLevel { get; } - - /// <summary>The current mine level.</summary> - public int CurrentMineLevel { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="previousMineLevel">The previous mine level.</param> - /// <param name="currentMineLevel">The current mine level.</param> - public EventArgsMineLevelChanged(int previousMineLevel, int currentMineLevel) - { - this.PreviousMineLevel = previousMineLevel; - this.CurrentMineLevel = currentMineLevel; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsMouseStateChanged.cs b/src/SMAPI/Events/EventArgsMouseStateChanged.cs deleted file mode 100644 index 09f3f759..00000000 --- a/src/SMAPI/Events/EventArgsMouseStateChanged.cs +++ /dev/null @@ -1,44 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using Microsoft.Xna.Framework; -using Microsoft.Xna.Framework.Input; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="ControlEvents.MouseChanged"/> event.</summary> - public class EventArgsMouseStateChanged : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous mouse state.</summary> - public MouseState PriorState { get; } - - /// <summary>The current mouse state.</summary> - public MouseState NewState { get; } - - /// <summary>The previous mouse position on the screen adjusted for the zoom level.</summary> - public Point PriorPosition { get; } - - /// <summary>The current mouse position on the screen adjusted for the zoom level.</summary> - public Point NewPosition { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorState">The previous mouse state.</param> - /// <param name="newState">The current mouse state.</param> - /// <param name="priorPosition">The previous mouse position on the screen adjusted for the zoom level.</param> - /// <param name="newPosition">The current mouse position on the screen adjusted for the zoom level.</param> - public EventArgsMouseStateChanged(MouseState priorState, MouseState newState, Point priorPosition, Point newPosition) - { - this.PriorState = priorState; - this.NewState = newState; - this.PriorPosition = priorPosition; - this.NewPosition = newPosition; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsPlayerWarped.cs b/src/SMAPI/Events/EventArgsPlayerWarped.cs deleted file mode 100644 index d1aa1588..00000000 --- a/src/SMAPI/Events/EventArgsPlayerWarped.cs +++ /dev/null @@ -1,34 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewValley; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a <see cref="PlayerEvents.Warped"/> event.</summary> - public class EventArgsPlayerWarped : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The player's previous location.</summary> - public GameLocation PriorLocation { get; } - - /// <summary>The player's current location.</summary> - public GameLocation NewLocation { get; } - - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorLocation">The player's previous location.</param> - /// <param name="newLocation">The player's current location.</param> - public EventArgsPlayerWarped(GameLocation priorLocation, GameLocation newLocation) - { - this.NewLocation = newLocation; - this.PriorLocation = priorLocation; - } - } -} -#endif diff --git a/src/SMAPI/Events/EventArgsValueChanged.cs b/src/SMAPI/Events/EventArgsValueChanged.cs deleted file mode 100644 index 7bfac7a2..00000000 --- a/src/SMAPI/Events/EventArgsValueChanged.cs +++ /dev/null @@ -1,33 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; - -namespace StardewModdingAPI.Events -{ - /// <summary>Event arguments for a field that changed value.</summary> - /// <typeparam name="T">The value type.</typeparam> - public class EventArgsValueChanged<T> : EventArgs - { - /********* - ** Accessors - *********/ - /// <summary>The previous value.</summary> - public T PriorValue { get; } - - /// <summary>The current value.</summary> - public T NewValue { get; } - - - /********* - ** Public methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="priorValue">The previous value.</param> - /// <param name="newValue">The current value.</param> - public EventArgsValueChanged(T priorValue, T newValue) - { - this.PriorValue = priorValue; - this.NewValue = newValue; - } - } -} -#endif diff --git a/src/SMAPI/Events/GameEvents.cs b/src/SMAPI/Events/GameEvents.cs deleted file mode 100644 index 9d945277..00000000 --- a/src/SMAPI/Events/GameEvents.cs +++ /dev/null @@ -1,122 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the game changes state.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class GameEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised when the game updates its state (≈60 times per second).</summary> - public static event EventHandler UpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_UpdateTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_UpdateTick.Remove(value); - } - - /// <summary>Raised every other tick (≈30 times per second).</summary> - public static event EventHandler SecondUpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_SecondUpdateTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_SecondUpdateTick.Remove(value); - } - - /// <summary>Raised every fourth tick (≈15 times per second).</summary> - public static event EventHandler FourthUpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_FourthUpdateTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_FourthUpdateTick.Remove(value); - } - - /// <summary>Raised every eighth tick (≈8 times per second).</summary> - public static event EventHandler EighthUpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_EighthUpdateTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_EighthUpdateTick.Remove(value); - } - - /// <summary>Raised every 15th tick (≈4 times per second).</summary> - public static event EventHandler QuarterSecondTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_QuarterSecondTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_QuarterSecondTick.Remove(value); - } - - /// <summary>Raised every 30th tick (≈twice per second).</summary> - public static event EventHandler HalfSecondTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_HalfSecondTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_HalfSecondTick.Remove(value); - } - - /// <summary>Raised every 60th tick (≈once per second).</summary> - public static event EventHandler OneSecondTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_OneSecondTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_OneSecondTick.Remove(value); - } - - /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> - public static event EventHandler FirstUpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GameEvents.EventManager.Legacy_FirstUpdateTick.Add(value); - } - remove => GameEvents.EventManager.Legacy_FirstUpdateTick.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - GameEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/GraphicsEvents.cs b/src/SMAPI/Events/GraphicsEvents.cs deleted file mode 100644 index 24a16a29..00000000 --- a/src/SMAPI/Events/GraphicsEvents.cs +++ /dev/null @@ -1,120 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised during the game's draw loop, when the game is rendering content to the window.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class GraphicsEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after the game window is resized.</summary> - public static event EventHandler Resize - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_Resize.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_Resize.Remove(value); - } - - /**** - ** Main render events - ****/ - /// <summary>Raised before drawing the world to the screen.</summary> - public static event EventHandler OnPreRenderEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPreRenderEvent.Remove(value); - } - - /// <summary>Raised after drawing the world to the screen.</summary> - public static event EventHandler OnPostRenderEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPostRenderEvent.Remove(value); - } - - /**** - ** HUD events - ****/ - /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public static event EventHandler OnPreRenderHudEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPreRenderHudEvent.Remove(value); - } - - /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public static event EventHandler OnPostRenderHudEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPostRenderHudEvent.Remove(value); - } - - /**** - ** GUI events - ****/ - /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public static event EventHandler OnPreRenderGuiEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPreRenderGuiEvent.Remove(value); - } - - /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public static event EventHandler OnPostRenderGuiEvent - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Add(value); - } - remove => GraphicsEvents.EventManager.Legacy_OnPostRenderGuiEvent.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - GraphicsEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/IGameLoopEvents.cs b/src/SMAPI/Events/IGameLoopEvents.cs index 6fb56c8b..6855737b 100644 --- a/src/SMAPI/Events/IGameLoopEvents.cs +++ b/src/SMAPI/Events/IGameLoopEvents.cs @@ -5,7 +5,7 @@ namespace StardewModdingAPI.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> public interface IGameLoopEvents { - /// <summary>Raised after the game is launched, right before the first update tick. This happens once per game session (unrelated to loading saves). All mods are loaded and initialised at this point, so this is a good time to set up mod integrations.</summary> + /// <summary>Raised after the game is launched, right before the first update tick. This happens once per game session (unrelated to loading saves). All mods are loaded and initialized at this point, so this is a good time to set up mod integrations.</summary> event EventHandler<GameLaunchedEventArgs> GameLaunched; /// <summary>Raised before the game state is updated (≈60 times per second).</summary> @@ -26,13 +26,13 @@ namespace StardewModdingAPI.Events /// <summary>Raised after the game finishes creating the save file.</summary> event EventHandler<SaveCreatedEventArgs> SaveCreated; - /// <summary>Raised before the game begins writes data to the save file (except the initial save creation).</summary> + /// <summary>Raised before the game begins writing data to the save file (except the initial save creation).</summary> event EventHandler<SavingEventArgs> Saving; /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> event EventHandler<SavedEventArgs> Saved; - /// <summary>Raised after the player loads a save slot and the world is initialised.</summary> + /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> event EventHandler<SaveLoadedEventArgs> SaveLoaded; /// <summary>Raised after the game begins a new day (including when the player loads a save).</summary> diff --git a/src/SMAPI/Events/IModEvents.cs b/src/SMAPI/Events/IModEvents.cs index bd7ab880..1f892b31 100644 --- a/src/SMAPI/Events/IModEvents.cs +++ b/src/SMAPI/Events/IModEvents.cs @@ -21,7 +21,7 @@ namespace StardewModdingAPI.Events /// <summary>Events raised when something changes in the world.</summary> IWorldEvents World { get; } - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - ISpecialisedEvents Specialised { get; } + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + ISpecializedEvents Specialized { get; } } } diff --git a/src/SMAPI/Events/ISpecialisedEvents.cs b/src/SMAPI/Events/ISpecialisedEvents.cs index ecb109e6..bf70956d 100644 --- a/src/SMAPI/Events/ISpecialisedEvents.cs +++ b/src/SMAPI/Events/ISpecialisedEvents.cs @@ -2,8 +2,8 @@ using System; namespace StardewModdingAPI.Events { - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - public interface ISpecialisedEvents + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + public interface ISpecializedEvents { /// <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> event EventHandler<LoadStageChangedEventArgs> LoadStageChanged; diff --git a/src/SMAPI/Events/InputEvents.cs b/src/SMAPI/Events/InputEvents.cs deleted file mode 100644 index c5ab8c83..00000000 --- a/src/SMAPI/Events/InputEvents.cs +++ /dev/null @@ -1,56 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the player uses a controller, keyboard, or mouse button.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class InputEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised when the player presses a button on the keyboard, controller, or mouse.</summary> - public static event EventHandler<EventArgsInput> ButtonPressed - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - InputEvents.EventManager.Legacy_ButtonPressed.Add(value); - } - remove => InputEvents.EventManager.Legacy_ButtonPressed.Remove(value); - } - - /// <summary>Raised when the player releases a keyboard key on the keyboard, controller, or mouse.</summary> - public static event EventHandler<EventArgsInput> ButtonReleased - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - InputEvents.EventManager.Legacy_ButtonReleased.Add(value); - } - remove => InputEvents.EventManager.Legacy_ButtonReleased.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - InputEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/LoadStageChangedEventArgs.cs b/src/SMAPI/Events/LoadStageChangedEventArgs.cs index e837a5f1..3529dcf3 100644 --- a/src/SMAPI/Events/LoadStageChangedEventArgs.cs +++ b/src/SMAPI/Events/LoadStageChangedEventArgs.cs @@ -3,7 +3,7 @@ using StardewModdingAPI.Enums; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="ISpecialisedEvents.LoadStageChanged"/> event.</summary> + /// <summary>Event arguments for an <see cref="ISpecializedEvents.LoadStageChanged"/> event.</summary> public class LoadStageChangedEventArgs : EventArgs { /********* diff --git a/src/SMAPI/Events/LocationEvents.cs b/src/SMAPI/Events/LocationEvents.cs deleted file mode 100644 index 0761bdd8..00000000 --- a/src/SMAPI/Events/LocationEvents.cs +++ /dev/null @@ -1,67 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the player transitions between game locations, a location is added or removed, or the objects in the current location change.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class LocationEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after a game location is added or removed.</summary> - public static event EventHandler<EventArgsLocationsChanged> LocationsChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - LocationEvents.EventManager.Legacy_LocationsChanged.Add(value); - } - remove => LocationEvents.EventManager.Legacy_LocationsChanged.Remove(value); - } - - /// <summary>Raised after buildings are added or removed in a location.</summary> - public static event EventHandler<EventArgsLocationBuildingsChanged> BuildingsChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - LocationEvents.EventManager.Legacy_BuildingsChanged.Add(value); - } - remove => LocationEvents.EventManager.Legacy_BuildingsChanged.Remove(value); - } - - /// <summary>Raised after objects are added or removed in a location.</summary> - public static event EventHandler<EventArgsLocationObjectsChanged> ObjectsChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - LocationEvents.EventManager.Legacy_ObjectsChanged.Add(value); - } - remove => LocationEvents.EventManager.Legacy_ObjectsChanged.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - LocationEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/MenuEvents.cs b/src/SMAPI/Events/MenuEvents.cs deleted file mode 100644 index 8647c268..00000000 --- a/src/SMAPI/Events/MenuEvents.cs +++ /dev/null @@ -1,56 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when a game menu is opened or closed (including internal menus like the title screen).</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class MenuEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> - public static event EventHandler<EventArgsClickableMenuChanged> MenuChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MenuEvents.EventManager.Legacy_MenuChanged.Add(value); - } - remove => MenuEvents.EventManager.Legacy_MenuChanged.Remove(value); - } - - /// <summary>Raised after a game menu is closed.</summary> - public static event EventHandler<EventArgsClickableMenuClosed> MenuClosed - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MenuEvents.EventManager.Legacy_MenuClosed.Add(value); - } - remove => MenuEvents.EventManager.Legacy_MenuClosed.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - MenuEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/MineEvents.cs b/src/SMAPI/Events/MineEvents.cs deleted file mode 100644 index 929da35b..00000000 --- a/src/SMAPI/Events/MineEvents.cs +++ /dev/null @@ -1,45 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when something happens in the mines.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class MineEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after the player warps to a new level of the mine.</summary> - public static event EventHandler<EventArgsMineLevelChanged> MineLevelChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MineEvents.EventManager.Legacy_MineLevelChanged.Add(value); - } - remove => MineEvents.EventManager.Legacy_MineLevelChanged.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - MineEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/MultiplayerEvents.cs b/src/SMAPI/Events/MultiplayerEvents.cs deleted file mode 100644 index 0650a8e2..00000000 --- a/src/SMAPI/Events/MultiplayerEvents.cs +++ /dev/null @@ -1,78 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised during the multiplayer sync process.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class MultiplayerEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised before the game syncs changes from other players.</summary> - public static event EventHandler BeforeMainSync - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Add(value); - } - remove => MultiplayerEvents.EventManager.Legacy_BeforeMainSync.Remove(value); - } - - /// <summary>Raised after the game syncs changes from other players.</summary> - public static event EventHandler AfterMainSync - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MultiplayerEvents.EventManager.Legacy_AfterMainSync.Add(value); - } - remove => MultiplayerEvents.EventManager.Legacy_AfterMainSync.Remove(value); - } - - /// <summary>Raised before the game broadcasts changes to other players.</summary> - public static event EventHandler BeforeMainBroadcast - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Add(value); - } - remove => MultiplayerEvents.EventManager.Legacy_BeforeMainBroadcast.Remove(value); - } - - /// <summary>Raised after the game broadcasts changes to other players.</summary> - public static event EventHandler AfterMainBroadcast - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Add(value); - } - remove => MultiplayerEvents.EventManager.Legacy_AfterMainBroadcast.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - MultiplayerEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/PlayerEvents.cs b/src/SMAPI/Events/PlayerEvents.cs deleted file mode 100644 index 11ba1e54..00000000 --- a/src/SMAPI/Events/PlayerEvents.cs +++ /dev/null @@ -1,68 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the player data changes.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class PlayerEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> - public static event EventHandler<EventArgsInventoryChanged> InventoryChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - PlayerEvents.EventManager.Legacy_InventoryChanged.Add(value); - } - remove => PlayerEvents.EventManager.Legacy_InventoryChanged.Remove(value); - } - - /// <summary>Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> - public static event EventHandler<EventArgsLevelUp> LeveledUp - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - PlayerEvents.EventManager.Legacy_LeveledUp.Add(value); - } - remove => PlayerEvents.EventManager.Legacy_LeveledUp.Remove(value); - } - - /// <summary>Raised after the player warps to a new location.</summary> - public static event EventHandler<EventArgsPlayerWarped> Warped - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - PlayerEvents.EventManager.Legacy_PlayerWarped.Add(value); - } - remove => PlayerEvents.EventManager.Legacy_PlayerWarped.Remove(value); - } - - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - PlayerEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/SaveEvents.cs b/src/SMAPI/Events/SaveEvents.cs deleted file mode 100644 index da276d22..00000000 --- a/src/SMAPI/Events/SaveEvents.cs +++ /dev/null @@ -1,100 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised before and after the player saves/loads the game.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class SaveEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised before the game creates the save file.</summary> - public static event EventHandler BeforeCreate - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_BeforeCreateSave.Add(value); - } - remove => SaveEvents.EventManager.Legacy_BeforeCreateSave.Remove(value); - } - - /// <summary>Raised after the game finishes creating the save file.</summary> - public static event EventHandler AfterCreate - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_AfterCreateSave.Add(value); - } - remove => SaveEvents.EventManager.Legacy_AfterCreateSave.Remove(value); - } - - /// <summary>Raised before the game begins writes data to the save file.</summary> - public static event EventHandler BeforeSave - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_BeforeSave.Add(value); - } - remove => SaveEvents.EventManager.Legacy_BeforeSave.Remove(value); - } - - /// <summary>Raised after the game finishes writing data to the save file.</summary> - public static event EventHandler AfterSave - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_AfterSave.Add(value); - } - remove => SaveEvents.EventManager.Legacy_AfterSave.Remove(value); - } - - /// <summary>Raised after the player loads a save slot.</summary> - public static event EventHandler AfterLoad - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_AfterLoad.Add(value); - } - remove => SaveEvents.EventManager.Legacy_AfterLoad.Remove(value); - } - - /// <summary>Raised after the game returns to the title screen.</summary> - public static event EventHandler AfterReturnToTitle - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SaveEvents.EventManager.Legacy_AfterReturnToTitle.Add(value); - } - remove => SaveEvents.EventManager.Legacy_AfterReturnToTitle.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - SaveEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/SpecialisedEvents.cs b/src/SMAPI/Events/SpecialisedEvents.cs deleted file mode 100644 index 4f16e4da..00000000 --- a/src/SMAPI/Events/SpecialisedEvents.cs +++ /dev/null @@ -1,45 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class SpecialisedEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> - public static event EventHandler UnvalidatedUpdateTick - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Add(value); - } - remove => SpecialisedEvents.EventManager.Legacy_UnvalidatedUpdateTick.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - SpecialisedEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/TimeEvents.cs b/src/SMAPI/Events/TimeEvents.cs deleted file mode 100644 index 389532d9..00000000 --- a/src/SMAPI/Events/TimeEvents.cs +++ /dev/null @@ -1,56 +0,0 @@ -#if !SMAPI_3_0_STRICT -using System; -using StardewModdingAPI.Framework; -using StardewModdingAPI.Framework.Events; - -namespace StardewModdingAPI.Events -{ - /// <summary>Events raised when the in-game date or time changes.</summary> - [Obsolete("Use " + nameof(Mod.Helper) + "." + nameof(IModHelper.Events) + " instead. See https://smapi.io/3.0 for more info.")] - public static class TimeEvents - { - /********* - ** Fields - *********/ - /// <summary>The core event manager.</summary> - private static EventManager EventManager; - - - /********* - ** Events - *********/ - /// <summary>Raised after the game begins a new day, including when loading a save.</summary> - public static event EventHandler AfterDayStarted - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - TimeEvents.EventManager.Legacy_AfterDayStarted.Add(value); - } - remove => TimeEvents.EventManager.Legacy_AfterDayStarted.Remove(value); - } - - /// <summary>Raised after the in-game clock changes.</summary> - public static event EventHandler<EventArgsIntChanged> TimeOfDayChanged - { - add - { - SCore.DeprecationManager.WarnForOldEvents(); - TimeEvents.EventManager.Legacy_TimeOfDayChanged.Add(value); - } - remove => TimeEvents.EventManager.Legacy_TimeOfDayChanged.Remove(value); - } - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the events.</summary> - /// <param name="eventManager">The core event manager.</param> - internal static void Init(EventManager eventManager) - { - TimeEvents.EventManager = eventManager; - } - } -} -#endif diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs index 13c367a0..258e2f99 100644 --- a/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs +++ b/src/SMAPI/Events/UnvalidatedUpdateTickedEventArgs.cs @@ -3,7 +3,7 @@ using StardewModdingAPI.Framework; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/> event.</summary> + /// <summary>Event arguments for an <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> event.</summary> public class UnvalidatedUpdateTickedEventArgs : EventArgs { /********* diff --git a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs index c2e60f25..e3c8b3ee 100644 --- a/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs +++ b/src/SMAPI/Events/UnvalidatedUpdateTickingEventArgs.cs @@ -3,7 +3,7 @@ using StardewModdingAPI.Framework; namespace StardewModdingAPI.Events { - /// <summary>Event arguments for an <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/> event.</summary> + /// <summary>Event arguments for an <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> event.</summary> public class UnvalidatedUpdateTickingEventArgs : EventArgs { /********* diff --git a/src/SMAPI/Framework/CommandManager.cs b/src/SMAPI/Framework/CommandManager.cs index fdaafff1..ceeb6f93 100644 --- a/src/SMAPI/Framework/CommandManager.cs +++ b/src/SMAPI/Framework/CommandManager.cs @@ -29,7 +29,7 @@ namespace StardewModdingAPI.Framework /// <exception cref="ArgumentException">There's already a command with that name.</exception> public void Add(IModMetadata mod, string name, string documentation, Action<string, string[]> callback, bool allowNullCallback = false) { - name = this.GetNormalisedName(name); + name = this.GetNormalizedName(name); // validate format if (string.IsNullOrWhiteSpace(name)) @@ -52,7 +52,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns the matching command, or <c>null</c> if not found.</returns> public Command Get(string name) { - name = this.GetNormalisedName(name); + name = this.GetNormalizedName(name); this.Commands.TryGetValue(name, out Command command); return command; } @@ -84,7 +84,7 @@ namespace StardewModdingAPI.Framework // parse input args = this.ParseArgs(input); - name = this.GetNormalisedName(args[0]); + name = this.GetNormalizedName(args[0]); args = args.Skip(1).ToArray(); // get command @@ -97,8 +97,8 @@ namespace StardewModdingAPI.Framework /// <returns>Returns whether a matching command was triggered.</returns> public bool Trigger(string name, string[] arguments) { - // get normalised name - name = this.GetNormalisedName(name); + // get normalized name + name = this.GetNormalizedName(name); if (name == null) return false; @@ -140,9 +140,9 @@ namespace StardewModdingAPI.Framework return args.Where(item => !string.IsNullOrWhiteSpace(item)).ToArray(); } - /// <summary>Get a normalised command name.</summary> + /// <summary>Get a normalized command name.</summary> /// <param name="name">The command name.</param> - private string GetNormalisedName(string name) + private string GetNormalizedName(string name) { name = name?.Trim().ToLower(); return !string.IsNullOrWhiteSpace(name) diff --git a/src/SMAPI/Framework/Content/AssetData.cs b/src/SMAPI/Framework/Content/AssetData.cs index 553404d3..cacc6078 100644 --- a/src/SMAPI/Framework/Content/AssetData.cs +++ b/src/SMAPI/Framework/Content/AssetData.cs @@ -24,13 +24,13 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <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> getNormalisedPath, Action<TValue> onDataReplaced) - : base(locale, assetName, data.GetType(), getNormalisedPath) + public AssetData(string locale, string assetName, TValue data, Func<string, string> getNormalizedPath, Action<TValue> onDataReplaced) + : base(locale, assetName, data.GetType(), getNormalizedPath) { this.Data = data; this.OnDataReplaced = onDataReplaced; diff --git a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs index 11a2564c..26cbff5a 100644 --- a/src/SMAPI/Framework/Content/AssetDataForDictionary.cs +++ b/src/SMAPI/Framework/Content/AssetDataForDictionary.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; namespace StardewModdingAPI.Framework.Content { @@ -11,44 +10,12 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <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> getNormalisedPath, Action<IDictionary<TKey, TValue>> onDataReplaced) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced) { } - -#if !SMAPI_3_0_STRICT - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">The entry value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(TKey key, TValue value) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - this.Data[key] = value; - } - - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">A callback which accepts the current value and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(TKey key, Func<TValue, TValue> value) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - this.Data[key] = value(this.Data[key]); - } - - /// <summary>Dynamically replace values in the dictionary.</summary> - /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - public void Set(Func<TKey, TValue, TValue> replacer) - { - SCore.DeprecationManager.Warn($"AssetDataForDictionary.{nameof(Set)}", "2.10", DeprecationLevel.PendingRemoval); - foreach (var pair in this.Data.ToArray()) - this.Data[pair.Key] = replacer(pair.Key, pair.Value); - } -#endif + public AssetDataForDictionary(string locale, string 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 f2d21b5e..4ae2ad68 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -19,13 +19,13 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <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> getNormalisedPath, Action<Texture2D> onDataReplaced) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced) { } + public AssetDataForImage(string locale, string assetName, Texture2D data, Func<string, string> getNormalizedPath, Action<Texture2D> onDataReplaced) + : base(locale, assetName, data, getNormalizedPath, onDataReplaced) { } /// <summary>Overwrite part of the image.</summary> /// <param name="source">The image to patch into the content.</param> diff --git a/src/SMAPI/Framework/Content/AssetDataForObject.cs b/src/SMAPI/Framework/Content/AssetDataForObject.cs index 90f9e2d4..4dbc988c 100644 --- a/src/SMAPI/Framework/Content/AssetDataForObject.cs +++ b/src/SMAPI/Framework/Content/AssetDataForObject.cs @@ -11,19 +11,19 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> - public AssetDataForObject(string locale, string assetName, object data, Func<string, string> getNormalisedPath) - : base(locale, assetName, data, getNormalisedPath, onDataReplaced: null) { } + /// <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) { } /// <summary>Construct an instance.</summary> /// <param name="info">The asset metadata.</param> /// <param name="data">The content data being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> - public AssetDataForObject(IAssetInfo info, object data, Func<string, string> getNormalisedPath) - : this(info.Locale, info.AssetName, data, getNormalisedPath) { } + /// <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) { } /// <summary>Get a helper to manipulate the data as a dictionary.</summary> /// <typeparam name="TKey">The expected dictionary key.</typeparam> @@ -31,14 +31,14 @@ namespace StardewModdingAPI.Framework.Content /// <exception cref="InvalidOperationException">The content being read isn't a dictionary.</exception> public IAssetDataForDictionary<TKey, TValue> AsDictionary<TKey, TValue>() { - return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalisedPath, this.ReplaceWith); + return new AssetDataForDictionary<TKey, TValue>(this.Locale, this.AssetName, this.GetData<IDictionary<TKey, TValue>>(), this.GetNormalizedPath, this.ReplaceWith); } /// <summary>Get a helper to manipulate the data as an image.</summary> /// <exception cref="InvalidOperationException">The content being read isn't an image.</exception> public IAssetDataForImage AsImage() { - return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalisedPath, this.ReplaceWith); + return new AssetDataForImage(this.Locale, this.AssetName, this.GetData<Texture2D>(), this.GetNormalizedPath, this.ReplaceWith); } /// <summary>Get the data as a given type.</summary> diff --git a/src/SMAPI/Framework/Content/AssetInfo.cs b/src/SMAPI/Framework/Content/AssetInfo.cs index e5211290..9b685e72 100644 --- a/src/SMAPI/Framework/Content/AssetInfo.cs +++ b/src/SMAPI/Framework/Content/AssetInfo.cs @@ -9,17 +9,17 @@ namespace StardewModdingAPI.Framework.Content /********* ** Fields *********/ - /// <summary>Normalises an asset key to match the cache key.</summary> - protected readonly Func<string, string> GetNormalisedPath; + /// <summary>Normalizes an asset key to match the cache key.</summary> + protected readonly Func<string, string> GetNormalizedPath; /********* ** Accessors *********/ - /// <summary>The content's locale code, if the content is localised.</summary> + /// <summary>The content's locale code, if the content is localized.</summary> public string Locale { get; } - /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> public string AssetName { get; } /// <summary>The content data type.</summary> @@ -30,23 +30,23 @@ namespace StardewModdingAPI.Framework.Content ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="locale">The content's locale code, if the content is localised.</param> - /// <param name="assetName">The normalised asset name being read.</param> + /// <param name="locale">The content's locale code, if the content is localized.</param> + /// <param name="assetName">The normalized asset name being read.</param> /// <param name="type">The content type being read.</param> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> - public AssetInfo(string locale, string assetName, Type type, Func<string, string> getNormalisedPath) + /// <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) { this.Locale = locale; this.AssetName = assetName; this.DataType = type; - this.GetNormalisedPath = getNormalisedPath; + this.GetNormalizedPath = getNormalizedPath; } - /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <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> public bool AssetNameEquals(string path) { - path = this.GetNormalisedPath(path); + path = this.GetNormalizedPath(path); return this.AssetName.Equals(path, StringComparison.InvariantCultureIgnoreCase); } diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs index 55a96ed2..4178b663 100644 --- a/src/SMAPI/Framework/Content/ContentCache.cs +++ b/src/SMAPI/Framework/Content/ContentCache.cs @@ -10,7 +10,7 @@ using StardewValley; namespace StardewModdingAPI.Framework.Content { - /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localisation, loading content, etc. It assumes all keys passed in are already normalised.</summary> + /// <summary>A low-level wrapper around the content cache which handles reading, writing, and invalidating entries in the cache. This doesn't handle any higher-level logic like localization, loading content, etc. It assumes all keys passed in are already normalized.</summary> internal class ContentCache { /********* @@ -19,8 +19,8 @@ namespace StardewModdingAPI.Framework.Content /// <summary>The underlying asset cache.</summary> private readonly IDictionary<string, object> Cache; - /// <summary>Applies platform-specific asset key normalisation so it's consistent with the underlying cache.</summary> - private readonly Func<string, string> NormaliseAssetNameForPlatform; + /// <summary>Applies platform-specific asset key normalization so it's consistent with the underlying cache.</summary> + private readonly Func<string, string> NormalizeAssetNameForPlatform; /********* @@ -52,14 +52,14 @@ namespace StardewModdingAPI.Framework.Content // init this.Cache = reflection.GetField<Dictionary<string, object>>(contentManager, "loadedAssets").GetValue(); - // get key normalisation logic + // get key normalization logic if (Constants.Platform == Platform.Windows) { IReflectedMethod method = reflection.GetMethod(typeof(TitleContainer), "GetCleanPath"); - this.NormaliseAssetNameForPlatform = path => method.Invoke<string>(path); + this.NormalizeAssetNameForPlatform = path => method.Invoke<string>(path); } else - this.NormaliseAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic + this.NormalizeAssetNameForPlatform = key => key.Replace('\\', '/'); // based on MonoGame's ContentManager.Load<T> logic } /**** @@ -74,25 +74,25 @@ namespace StardewModdingAPI.Framework.Content /**** - ** Normalise + ** Normalize ****/ - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="NormaliseKey"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Normalize path separators in a file path. For asset keys, see <see cref="NormalizeKey"/> instead.</summary> + /// <param name="path">The file path to normalize.</param> [Pure] - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return PathUtilities.NormalisePathSeparators(path); + return PathUtilities.NormalizePathSeparators(path); } - /// <summary>Normalise a cache key so it's consistent with the underlying cache.</summary> + /// <summary>Normalize a cache key so it's consistent with the underlying cache.</summary> /// <param name="key">The asset key.</param> [Pure] - public string NormaliseKey(string key) + public string NormalizeKey(string key) { - key = this.NormalisePathSeparators(key); + key = this.NormalizePathSeparators(key); return key.EndsWith(".xnb", StringComparison.InvariantCultureIgnoreCase) ? key.Substring(0, key.Length - 4) - : this.NormaliseAssetNameForPlatform(key); + : this.NormalizeAssetNameForPlatform(key); } /**** diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs index ee654081..08ebe6a5 100644 --- a/src/SMAPI/Framework/ContentCoordinator.cs +++ b/src/SMAPI/Framework/ContentCoordinator.cs @@ -9,7 +9,7 @@ using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Metadata; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -36,9 +36,15 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <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>The language code for language-agnostic mod assets.</summary> + private readonly LocalizedContentManager.LanguageCode DefaultLanguage = Constants.DefaultLanguage; + /// <summary>Whether the content coordinator has been disposed.</summary> private bool IsDisposed; @@ -68,27 +74,29 @@ namespace StardewModdingAPI.Framework /// <summary>Construct an instance.</summary> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public ContentCoordinator(IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset) { 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.ContentManagers.Add( - this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing) + this.MainContentManager = new GameContentManager("Game1.content", serviceProvider, rootDirectory, currentCulture, this, monitor, reflection, this.OnDisposing, onLoadingFirstAsset) ); - this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormaliseAssetName, reflection, monitor); + this.CoreAssets = new CoreAssetPropagator(this.MainContentManager.AssertAndNormalizeAssetName, reflection, monitor); } /// <summary>Get a new content manager which handles reading files from the game content folder with support for interception.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> public GameContentManager CreateGameContentManager(string name) { - GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing); + GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset); this.ContentManagers.Add(manager); return manager; } @@ -96,9 +104,21 @@ namespace StardewModdingAPI.Framework /// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="rootDirectory">The root directory to search for content (or <c>null</c> for the default).</param> - public ModContentManager CreateModContentManager(string name, string rootDirectory) + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> + public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager) { - ModContentManager manager = new ModContentManager(name, this.MainContentManager.ServiceProvider, rootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.JsonHelper, this.OnDisposing); + ModContentManager manager = new ModContentManager( + name: name, + gameContentManager: gameContentManager, + serviceProvider: this.MainContentManager.ServiceProvider, + rootDirectory: rootDirectory, + currentCulture: this.MainContentManager.CurrentCulture, + coordinator: this, + monitor: this.Monitor, + reflection: this.Reflection, + jsonHelper: this.JsonHelper, + onDisposing: this.OnDisposing + ); this.ContentManagers.Add(manager); return manager; } @@ -109,6 +129,13 @@ namespace StardewModdingAPI.Framework return this.MainContentManager.GetLocale(LocalizedContentManager.CurrentLanguageCode); } + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public void OnLocaleChanged() + { + foreach (IContentManager contentManager in this.ContentManagers) + contentManager.OnLocaleChanged(); + } + /// <summary>Get whether this asset is mapped to a mod folder.</summary> /// <param name="key">The asset key.</param> public bool IsManagedAssetKey(string key) @@ -148,20 +175,17 @@ namespace StardewModdingAPI.Framework /// <summary>Get a copy of an asset from a mod folder.</summary> /// <typeparam name="T">The asset type.</typeparam> - /// <param name="internalKey">The internal asset key.</param> /// <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> - /// <param name="language">The language code for which to load content.</param> - public T LoadAndCloneManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LocalizedContentManager.LanguageCode language) + public T LoadManagedAsset<T>(string contentManagerID, string relativePath) { // get content manager - IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.Name == contentManagerID); + IContentManager contentManager = 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 cloned asset - T data = contentManager.Load<T>(internalKey, language); - return contentManager.CloneIfPossible(data); + // get fresh asset + return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false); } /// <summary>Purge assets from the cache that match one of the interceptors.</summary> @@ -226,7 +250,7 @@ namespace StardewModdingAPI.Framework string locale = this.GetLocale(); return this.InvalidateCache((assetName, type) => { - IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormaliseAssetName); + IAssetInfo info = new AssetInfo(locale, assetName, type, this.MainContentManager.AssertAndNormalizeAssetName); return predicate(info); }, dispose); } @@ -246,14 +270,7 @@ namespace StardewModdingAPI.Framework } // reload core game assets - int reloaded = 0; - foreach (var pair in removedAssetNames) - { - string key = pair.Key; - Type type = pair.Value; - if (this.CoreAssets.Propagate(this.MainContentManager, key, type)) // use an intercepted content manager - reloaded++; - } + int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager // report result if (removedAssetNames.Any()) diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs index 7821e454..5283340e 100644 --- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs @@ -38,6 +38,9 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The language enum values indexed by locale code.</summary> protected IDictionary<string, LanguageCode> LanguageCodes { get; } + /// <summary>A list of disposable assets.</summary> + private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>(); + /********* ** Accessors @@ -51,8 +54,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> public string FullRootDirectory => Path.Combine(Constants.ExecutionPath, this.RootDirectory); - /// <summary>Whether this content manager is for a mod folder.</summary> - public bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + public bool IsNamespaced { get; } /********* @@ -62,13 +65,13 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - /// <param name="isModFolder">Whether this content manager is for a mod folder.</param> - protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isModFolder) + /// <param name="isNamespaced">Whether this content manager handles managed asset keys (e.g. to load assets from a mod folder).</param> + protected BaseContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, bool isNamespaced) : base(serviceProvider, rootDirectory, currentCulture) { // init @@ -77,7 +80,7 @@ namespace StardewModdingAPI.Framework.ContentManagers this.Cache = new ContentCache(this, reflection); this.Monitor = monitor ?? throw new ArgumentNullException(nameof(monitor)); this.OnDisposing = onDisposing; - this.IsModContentManager = isModFolder; + this.IsNamespaced = isNamespaced; // get asset data this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); @@ -88,69 +91,50 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public override T Load<T>(string assetName) { - return this.Load<T>(assetName, LocalizedContentManager.CurrentLanguageCode); + return this.Load<T>(assetName, this.Language, useCache: true); } - /// <summary>Load the base asset without localisation.</summary> + /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - public override T LoadBase<T>(string assetName) + /// <param name="language">The language code for which to load content.</param> + public override T Load<T>(string assetName, LanguageCode language) { - return this.Load<T>(assetName, LanguageCode.en); + return this.Load<T>(assetName, language, useCache: true); } - /// <summary>Inject an asset into the cache.</summary> - /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="value">The asset value.</param> - public void Inject<T>(string assetName, T value) - { - assetName = this.AssertAndNormaliseAssetName(assetName); - this.Cache[assetName] = value; - - } + /// <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> + public abstract T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache); - /// <summary>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - public T CloneIfPossible<T>(T asset) + /// <summary>Load the base asset without localization.</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> + [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) { - switch (asset as object) - { - case Texture2D source: - { - int[] pixels = new int[source.Width * source.Height]; - source.GetData(pixels); - - Texture2D clone = new Texture2D(source.GraphicsDevice, source.Width, source.Height); - clone.SetData(pixels); - return (T)(object)clone; - } - - case Dictionary<string, string> source: - return (T)(object)new Dictionary<string, string>(source); - - case Dictionary<int, string> source: - return (T)(object)new Dictionary<int, string>(source); - - default: - return asset; - } + return this.Load<T>(assetName, LanguageCode.en, useCache: true); } - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public virtual void OnLocaleChanged() { } + + /// <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] - public string NormalisePathSeparators(string path) + public string NormalizePathSeparators(string path) { - return this.Cache.NormalisePathSeparators(path); + return this.Cache.NormalizePathSeparators(path); } - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <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> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - public string AssertAndNormaliseAssetName(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. @@ -159,7 +143,7 @@ namespace StardewModdingAPI.Framework.ContentManagers if (assetName.Intersect(Path.GetInvalidPathChars()).Any()) throw new SContentLoadException("The asset key or local path contains invalid characters."); - return this.Cache.NormaliseKey(assetName); + return this.Cache.NormalizeKey(assetName); } /**** @@ -182,8 +166,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> public bool IsLoaded(string assetName) { - assetName = this.Cache.NormaliseKey(assetName); - return this.IsNormalisedKeyLoaded(assetName); + assetName = this.Cache.NormalizeKey(assetName); + return this.IsNormalizedKeyLoaded(assetName); } /// <summary>Get the cached asset keys.</summary> @@ -225,11 +209,28 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="isDisposing">Whether the content manager is being disposed (rather than finalized).</param> protected override void Dispose(bool isDisposing) { + // ignore if disposed if (this.IsDisposed) return; this.IsDisposed = true; + // dispose uncached assets + foreach (WeakReference<IDisposable> reference in this.Disposables) + { + if (reference.TryGetTarget(out IDisposable disposable)) + { + try + { + disposable.Dispose(); + } + catch { /* ignore dispose errors */ } + } + } + this.Disposables.Clear(); + + // raise event this.OnDisposing(this); + base.Dispose(isDisposing); } @@ -246,32 +247,40 @@ namespace StardewModdingAPI.Framework.ContentManagers /********* ** Private methods *********/ - /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> - private IDictionary<LanguageCode, string> GetKeyLocales() + /// <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) { - // create locale => code map - IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); - foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) - map[code] = this.GetLocale(code); - - return map; + return useCache + ? base.LoadBase<T>(assetName) + : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); } - /// <summary>Get the asset name from a cache key.</summary> - /// <param name="cacheKey">The input cache key.</param> - private string GetAssetName(string cacheKey) + /// <summary>Inject an asset into the cache.</summary> + /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="value">The asset value.</param> + /// <param name="language">The language code for which to inject the asset.</param> + protected virtual void Inject<T>(string assetName, T value, LanguageCode language) { - this.ParseCacheKey(cacheKey, out string assetName, out string _); - return assetName; + // track asset key + if (value is Texture2D texture) + texture.Name = assetName; + + // cache asset + assetName = this.AssertAndNormalizeAssetName(assetName); + this.Cache[assetName] = value; } /// <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 localised).</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 localised key + // handle localized key if (!string.IsNullOrWhiteSpace(cacheKey)) { int lastSepIndex = cacheKey.LastIndexOf(".", StringComparison.InvariantCulture); @@ -293,7 +302,26 @@ namespace StardewModdingAPI.Framework.ContentManagers } /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected abstract bool IsNormalisedKeyLoaded(string normalisedAssetName); + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected abstract bool IsNormalizedKeyLoaded(string normalizedAssetName); + + /// <summary>Get the locale codes (like <c>ja-JP</c>) used in asset keys.</summary> + private IDictionary<LanguageCode, string> GetKeyLocales() + { + // create locale => code map + IDictionary<LanguageCode, string> map = new Dictionary<LanguageCode, string>(); + foreach (LanguageCode code in Enum.GetValues(typeof(LanguageCode))) + map[code] = this.GetLocale(code); + + return map; + } + + /// <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 ee940cc7..0b563555 100644 --- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; @@ -25,8 +26,14 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Interceptors which edit matching assets after they're loaded.</summary> private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors; - /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary> - private readonly IDictionary<string, bool> IsLocalisableLookup; + /// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary> + private readonly IDictionary<string, bool> IsLocalizableLookup; + + /// <summary>Whether the next load is the first for any game content manager.</summary> + private static bool IsFirstLoad = true; + + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + private readonly Action OnLoadingFirstAsset; /********* @@ -36,37 +43,48 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: false) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public GameContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, Action<BaseContentManager> onDisposing, Action onLoadingFirstAsset) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: false) { - this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + this.IsLocalizableLookup = reflection.GetField<IDictionary<string, bool>>(this, "_localizedAsset").GetValue(); + this.OnLoadingFirstAsset = onLoadingFirstAsset; } /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> - public override T Load<T>(string assetName, LanguageCode language) + /// <param name="useCache">Whether to read/write the loaded asset to the asset cache.</param> + public override T Load<T>(string assetName, LocalizedContentManager.LanguageCode language, bool useCache) { - // normalise asset name - assetName = this.AssertAndNormaliseAssetName(assetName); + // raise first-load callback + if (GameContentManager.IsFirstLoad) + { + GameContentManager.IsFirstLoad = false; + 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); + return this.Load<T>(newAssetName, newLanguage, useCache); // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + if (useCache && this.IsLoaded(assetName)) + return this.RawLoad<T>(assetName, language, useCache: true); // get managed asset if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T managedAsset = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, managedAsset); + T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); + if (useCache) + this.Inject(assetName, managedAsset, language); return managedAsset; } @@ -76,27 +94,50 @@ namespace StardewModdingAPI.Framework.ContentManagers { this.Monitor.Log($"Broke loop while loading asset '{assetName}'.", LogLevel.Warn); this.Monitor.Log($"Bypassing mod loaders for this asset. Stack trace:\n{Environment.StackTrace}", LogLevel.Trace); - data = base.Load<T>(assetName, language); + data = this.RawLoad<T>(assetName, language, useCache); } else { data = this.AssetsBeingLoaded.Track(assetName, () => { string locale = this.GetLocale(language); - IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormaliseAssetName); + IAssetInfo info = new AssetInfo(locale, assetName, typeof(T), this.AssertAndNormalizeAssetName); IAssetData asset = this.ApplyLoader<T>(info) - ?? new AssetDataForObject(info, base.Load<T>(assetName, language), this.AssertAndNormaliseAssetName); + ?? new AssetDataForObject(info, this.RawLoad<T>(assetName, language, useCache), this.AssertAndNormalizeAssetName); asset = this.ApplyEditors<T>(info, asset); return (T)asset.Data; }); } // update cache & return data - this.Inject(assetName, data); + this.Inject(assetName, data, language); return data; } + /// <summary>Perform any cleanup needed when the locale changes.</summary> + public override void OnLocaleChanged() + { + base.OnLocaleChanged(); + + // find assets for which a translatable version was loaded + HashSet<string> removeAssetNames = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in this.IsLocalizableLookup.Where(p => 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.Item1) + .OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase) + .ToArray(); + if (invalidated.Any()) + this.Monitor.Log($"Invalidated {invalidated.Length} asset names: {string.Join(", ", invalidated)} for locale change.", LogLevel.Trace); + } + /// <summary>Create a new content manager for temporary use.</summary> public override LocalizedContentManager CreateTemporary() { @@ -108,30 +149,107 @@ namespace StardewModdingAPI.Framework.ContentManagers ** Private methods *********/ /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) { // default English - if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalisedAssetName)) - return this.Cache.ContainsKey(normalisedAssetName); + if (this.Language == LocalizedContentManager.LanguageCode.en || this.Coordinator.IsManagedAssetKey(normalizedAssetName)) + return this.Cache.ContainsKey(normalizedAssetName); // translated - string localeKey = $"{normalisedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; - if (this.IsLocalisableLookup.TryGetValue(localeKey, out bool localisable)) + string keyWithLocale = $"{normalizedAssetName}.{this.GetLocale(this.GetCurrentLanguage())}"; + if (this.IsLocalizableLookup.TryGetValue(keyWithLocale, out bool localizable)) { - return localisable - ? this.Cache.ContainsKey(localeKey) - : this.Cache.ContainsKey(normalisedAssetName); + return localizable + ? this.Cache.ContainsKey(keyWithLocale) + : this.Cache.ContainsKey(normalizedAssetName); } // not loaded yet return false; } + /// <summary>Inject an asset into the cache.</summary> + /// <typeparam name="T">The type of asset to inject.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="value">The asset value.</param> + /// <param name="language">The language code for which to inject the asset.</param> + protected override void Inject<T>(string assetName, T value, LanguageCode language) + { + // handle explicit language in asset name + { + if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) + { + this.Inject(newAssetName, value, newLanguage); + 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`. + string keyWithLocale = $"{assetName}.{this.GetLocale(language)}"; + base.Inject(assetName, value, language); + if (this.Cache.ContainsKey(keyWithLocale)) + base.Inject(keyWithLocale, value, language); + + // track whether the injected asset is translatable for is-loaded lookups + if (this.Cache.ContainsKey(keyWithLocale)) + { + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[keyWithLocale] = true; + } + else if (this.Cache.ContainsKey(assetName)) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[keyWithLocale] = false; + } + 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) + { + // try translated asset + if (language != LocalizedContentManager.LanguageCode.en) + { + string translatedKey = $"{assetName}.{this.GetLocale(language)}"; + if (!this.IsLocalizableLookup.TryGetValue(translatedKey, out bool isTranslatable) || isTranslatable) + { + try + { + T obj = base.RawLoad<T>(translatedKey, useCache); + this.IsLocalizableLookup[assetName] = true; + this.IsLocalizableLookup[translatedKey] = true; + return obj; + } + catch (ContentLoadException) + { + this.IsLocalizableLookup[assetName] = false; + this.IsLocalizableLookup[translatedKey] = false; + } + } + } + + // try base asset + return base.RawLoad<T>(assetName, useCache); + } + /// <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)) @@ -188,7 +306,7 @@ namespace StardewModdingAPI.Framework.ContentManagers T data; try { - data = this.CloneIfPossible(loader.Load<T>(info)); + data = loader.Load<T>(info); this.Monitor.Log($"{mod.DisplayName} loaded asset '{info.AssetName}'.", LogLevel.Trace); } catch (Exception ex) @@ -205,7 +323,7 @@ namespace StardewModdingAPI.Framework.ContentManagers } // return matched asset - return new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + return new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); } /// <summary>Apply any <see cref="Editors"/> to a loaded asset.</summary> @@ -214,7 +332,7 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <param name="asset">The loaded asset.</param> private IAssetData ApplyEditors<T>(IAssetInfo info, IAssetData asset) { - IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormaliseAssetName); + IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName); // edit asset foreach (var entry in this.GetInterceptors(this.Editors)) diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs index 17618edd..12c01352 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.CodeAnalysis; using System.Diagnostics.Contracts; using Microsoft.Xna.Framework.Content; using StardewModdingAPI.Framework.Exceptions; @@ -23,8 +22,8 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary> string FullRootDirectory { get; } - /// <summary>Whether this content manager is for a mod folder.</summary> - bool IsModContentManager { get; } + /// <summary>Whether this content manager can be targeted by managed asset keys (e.g. to load assets from a mod folder).</summary> + bool IsNamespaced { get; } /********* @@ -33,35 +32,22 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - T Load<T>(string assetName); - - /// <summary>Load an asset that has been processed by the content pipeline.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> - T Load<T>(string assetName, LocalizedContentManager.LanguageCode language); - - /// <summary>Inject an asset into the cache.</summary> - /// <typeparam name="T">The type of asset to inject.</typeparam> - /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> - /// <param name="value">The asset value.</param> - void Inject<T>(string assetName, T value); + /// <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); - /// <summary>Get a copy of the given asset if supported.</summary> - /// <typeparam name="T">The asset type.</typeparam> - /// <param name="asset">The asset to clone.</param> - T CloneIfPossible<T>(T asset); + /// <summary>Perform any cleanup needed when the locale changes.</summary> + void OnLocaleChanged(); - /// <summary>Normalise path separators in a file path. For asset keys, see <see cref="AssertAndNormaliseAssetName"/> instead.</summary> - /// <param name="path">The file path to normalise.</param> + /// <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 NormalisePathSeparators(string path); + string NormalizePathSeparators(string path); - /// <summary>Assert that the given key has a valid format and return a normalised form consistent with the underlying cache.</summary> + /// <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> - [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - string AssertAndNormaliseAssetName(string assetName); + string AssertAndNormalizeAssetName(string assetName); /// <summary>Get the current content locale.</summary> string GetLocale(); diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs index 2c50ec04..90b86179 100644 --- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs +++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs @@ -1,12 +1,19 @@ using System; using System.Globalization; using System.IO; +using System.Linq; using Microsoft.Xna.Framework; +using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Exceptions; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Utilities; using StardewValley; +using xTile; +using xTile.Format; +using xTile.ObjectModel; +using xTile.Tiles; namespace StardewModdingAPI.Framework.ContentManagers { @@ -19,84 +26,89 @@ namespace StardewModdingAPI.Framework.ContentManagers /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> private readonly JsonHelper JsonHelper; + /// <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; + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param> + /// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="rootDirectory">The root directory to search for content.</param> - /// <param name="currentCulture">The current culture for which to localise content.</param> + /// <param name="currentCulture">The current culture for which to localize content.</param> /// <param name="coordinator">The central coordinator which manages content managers.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="onDisposing">A callback to invoke when the content manager is being disposed.</param> - public ModContentManager(string name, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) - : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isModFolder: true) + public ModContentManager(string name, IContentManager gameContentManager, IServiceProvider serviceProvider, string rootDirectory, CultureInfo currentCulture, ContentCoordinator coordinator, IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action<BaseContentManager> onDisposing) + : base(name, serviceProvider, rootDirectory, currentCulture, coordinator, monitor, reflection, onDisposing, isNamespaced: true) { + this.GameContentManager = gameContentManager; this.JsonHelper = jsonHelper; } /// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <typeparam name="T">The type of asset to load.</typeparam> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + public override T Load<T>(string assetName) + { + return this.Load<T>(assetName, this.DefaultLanguage, useCache: false); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="language">The language code for which to load content.</param> public override T Load<T>(string assetName, LanguageCode language) { - assetName = this.AssertAndNormaliseAssetName(assetName); + return this.Load<T>(assetName, language, useCache: false); + } + + /// <summary>Load an asset that has been processed by the content pipeline.</summary> + /// <typeparam name="T">The type of asset to load.</typeparam> + /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> + /// <param name="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> + public override T Load<T>(string assetName, LanguageCode language, bool useCache) + { + assetName = this.AssertAndNormalizeAssetName(assetName); - // get from cache - if (this.IsLoaded(assetName)) - return base.Load<T>(assetName, language); + // 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 + // others (or even fresh content managers). See https://www.patreon.com/posts/27247161 + // for more background info. + if (useCache) + throw new InvalidOperationException("Mod content managers don't support asset caching."); - // get managed asset - if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) + // 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 (contentManagerID != this.Name) + if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) { - T data = this.Coordinator.LoadAndCloneManagedAsset<T>(assetName, contentManagerID, relativePath, language); - this.Inject(assetName, data); - return data; + if (contentManagerID != this.Name) + throw new SContentLoadException($"Can't load managed asset key '{assetName}' through content manager '{this.Name}' for a different mod."); + assetName = relativePath; } - - return this.LoadManagedAsset<T>(assetName, contentManagerID, relativePath, language); } - throw new NotSupportedException("Can't load content folder asset from a mod content manager."); - } - - /// <summary>Create a new content manager for temporary use.</summary> - public override LocalizedContentManager CreateTemporary() - { - throw new NotSupportedException("Can't create a temporary mod content manager."); - } - - - /********* - ** Private methods - *********/ - /// <summary>Get whether an asset has already been loaded.</summary> - /// <param name="normalisedAssetName">The normalised asset name.</param> - protected override bool IsNormalisedKeyLoaded(string normalisedAssetName) - { - return this.Cache.ContainsKey(normalisedAssetName); - } - - /// <summary>Load a managed mod asset.</summary> - /// <typeparam name="T">The type of asset to load.</typeparam> - /// <param name="internalKey">The internal 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="language">The language code for which to load content.</param> - private T LoadManagedAsset<T>(string internalKey, string contentManagerID, string relativePath, LanguageCode language) - { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{relativePath}' from {contentManagerID}: {reasonPhrase}"); + // get local asset + SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); try { // get file - FileInfo file = this.GetModFile(relativePath); + FileInfo file = this.GetModFile(assetName); if (!file.Exists) throw GetContentError("the specified path doesn't exist."); @@ -105,35 +117,54 @@ namespace StardewModdingAPI.Framework.ContentManagers { // XNB file case ".xnb": - return base.Load<T>(relativePath, language); + { + T data = this.RawLoad<T>(assetName, useCache: false); + if (data is Map map) + { + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + } + return data; + } // unpacked data case ".json": { if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data)) throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above - return data; } // unpacked image case ".png": - // validate - if (typeof(T) != typeof(Texture2D)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); - - // fetch & cache - using (FileStream stream = File.OpenRead(file.FullName)) { - Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); - texture = this.PremultiplyTransparency(texture); - this.Inject(internalKey, texture); - return (T)(object)texture; + // 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); + return (T)(object)texture; + } } // unpacked map case ".tbin": - throw GetContentError($"can't read unpacked map file directly from the underlying content manager. It must be loaded through the mod's {typeof(IModHelper)}.{nameof(IModHelper.Content)} helper."); + { + // validate + if (typeof(T) != typeof(Map)) + throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); + + // fetch & cache + FormatManager formatManager = FormatManager.Instance; + Map map = formatManager.LoadMap(file.FullName); + this.NormalizeTilesheetPaths(map); + this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); + return (T)(object)map; + } default: throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'."); @@ -143,10 +174,37 @@ namespace StardewModdingAPI.Framework.ContentManagers { if (ex.GetInnermostException() is DllNotFoundException dllEx && dllEx.Message == "libgdiplus.dylib") throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); - throw new SContentLoadException($"The content manager failed loading content asset '{relativePath}' from {contentManagerID}.", ex); + throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); } } + /// <summary>Create a new content manager for temporary use.</summary> + public override LocalizedContentManager CreateTemporary() + { + throw new NotSupportedException("Can't create a temporary mod content manager."); + } + + /// <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) + { + FileInfo file = this.GetModFile(key); + string relativePath = PathUtilities.GetRelativePath(this.RootDirectory, file.FullName); + return Path.Combine(this.Name, relativePath); + } + + + /********* + ** Private methods + *********/ + /// <summary>Get whether an asset has already been loaded.</summary> + /// <param name="normalizedAssetName">The normalized asset name.</param> + protected override bool IsNormalizedKeyLoaded(string normalizedAssetName) + { + return this.Cache.ContainsKey(normalizedAssetName); + } + /// <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) @@ -182,9 +240,161 @@ namespace StardewModdingAPI.Framework.ContentManagers Color[] data = new Color[texture.Width * texture.Height]; texture.GetData(data); for (int i = 0; i < data.Length; i++) + { + if (data[i].A == 0) + continue; // no need to change fully transparent pixels + data[i] = Color.FromNonPremultiplied(data[i].ToVector4()); + } + texture.SetData(data); return texture; } + + /// <summary>Normalize map tilesheet paths for the current platform.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + private void NormalizeTilesheetPaths(Map map) + { + foreach (TileSheet tilesheet in map.TileSheets) + tilesheet.ImageSource = this.NormalizePathSeparators(tilesheet.ImageSource); + } + + /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> + /// <param name="map">The map whose tilesheets to fix.</param> + /// <param name="relativeMapPath">The relative map path within the mod folder.</param> + /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> + /// <remarks> + /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialized. It boils + /// down to this: + /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded + /// as-is relative to the <c>Content</c> folder. + /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. + /// + /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. + /// Instead we use a more heuristic approach: check relative to the map file first, then relative to + /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a + /// seasonal variation and then an exact match. + /// + /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. + /// </remarks> + private void FixCustomTilesheetPaths(Map map, string relativeMapPath) + { + // get map info + if (!map.TileSheets.Any()) + return; + relativeMapPath = this.AssertAndNormalizeAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators + string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder + bool isOutdoors = map.Properties.TryGetValue("Outdoors", out PropertyValue outdoorsProperty) && outdoorsProperty != null; + + // fix tilesheets + foreach (TileSheet tilesheet in map.TileSheets) + { + string imageSource = tilesheet.ImageSource; + + // validate tilesheet path + if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); + + // get seasonal name (if applicable) + string seasonalImageSource = null; + if (isOutdoors && Context.IsSaveLoaded && Game1.currentSeason != null) + { + string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); + bool hasSeasonalPrefix = + filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) + || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); + if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) + { + string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); + seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; + } + } + + // load best match + try + { + string key = + this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) + ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); + if (key != null) + { + tilesheet.ImageSource = key; + continue; + } + } + catch (Exception ex) + { + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); + } + + // none found + throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); + } + } + + /// <summary>Get the actual asset name for a tilesheet.</summary> + /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> + /// <param name="imageSource">The tilesheet image source to load.</param> + /// <returns>Returns the asset name.</returns> + /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> + private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) + { + if (imageSource == null) + return null; + + // check relative to map file + { + string localKey = Path.Combine(modRelativeMapFolder, imageSource); + FileInfo localFile = this.GetModFile(localKey); + if (localFile.Exists) + return this.GetInternalAssetKey(localKey); + } + + // check relative to content folder + { + foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) + { + string contentKey = candidateKey.EndsWith(".png") + ? candidateKey.Substring(0, candidateKey.Length - 4) + : candidateKey; + + try + { + this.GameContentManager.Load<Texture2D>(contentKey, this.Language, useCache: true); // no need to bypass cache here, since we're not storing the asset + return contentKey; + } + catch + { + // ignore file-not-found errors + // TODO: while it's useful to suppress an asset-not-found error here to avoid + // confusion, this is a pretty naive approach. Even if the file doesn't exist, + // the file may have been loaded through an IAssetLoader which failed. So even + // if the content file doesn't exist, that doesn't mean the error here is a + // content-not-found error. Unfortunately XNA doesn't provide a good way to + // detect the error type. + if (this.GetContentFolderFileExists(contentKey)) + throw; + } + } + } + + // not found + return null; + } + + /// <summary>Get whether a file from the game's content folder exists.</summary> + /// <param name="key">The asset key.</param> + private bool GetContentFolderFileExists(string key) + { + // get file path + string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); + if (!path.EndsWith(".xnb")) + path += ".xnb"; + + // get file + return new FileInfo(path).Exists; + } } } diff --git a/src/SMAPI/Framework/ContentPack.cs b/src/SMAPI/Framework/ContentPack.cs index e39d03a1..9c0bb9d1 100644 --- a/src/SMAPI/Framework/ContentPack.cs +++ b/src/SMAPI/Framework/ContentPack.cs @@ -2,7 +2,7 @@ using System; using System.IO; using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using xTile; @@ -30,6 +30,9 @@ namespace StardewModdingAPI.Framework /// <summary>The content pack's manifest.</summary> public IManifest Manifest { get; } + /// <summary>Provides translations stored in the content pack's <c>i18n</c> folder. See <see cref="IModHelper.Translation"/> for more info.</summary> + public ITranslationHelper Translation { get; } + /********* ** Public methods @@ -38,26 +41,36 @@ namespace StardewModdingAPI.Framework /// <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="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, JsonHelper jsonHelper) + public ContentPack(string directoryPath, IManifest manifest, IContentHelper content, ITranslationHelper translation, JsonHelper jsonHelper) { this.DirectoryPath = directoryPath; this.Manifest = manifest; this.Content = content; + this.Translation = translation; this.JsonHelper = jsonHelper; } + /// <summary>Get whether a given file exists in the content pack.</summary> + /// <param name="path">The file path to check.</param> + public bool HasFile(string path) + { + this.AssertRelativePath(path, nameof(this.HasFile)); + + return File.Exists(Path.Combine(this.DirectoryPath, path)); + } + /// <summary>Read a JSON file from the content pack folder.</summary> /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the contnet directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <param name="path">The file path relative to the content directory.</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> public TModel ReadJsonFile<TModel>(string path) where TModel : class { - if (!PathUtilities.IsSafeRelativePath(path)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.ReadJsonFile)} with a relative path."); + this.AssertRelativePath(path, nameof(this.ReadJsonFile)); - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalizePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel model) ? model : null; @@ -70,10 +83,9 @@ namespace StardewModdingAPI.Framework /// <exception cref="InvalidOperationException">The <paramref name="path"/> is not relative or contains directory climbing (../).</exception> public void WriteJsonFile<TModel>(string path, TModel data) where TModel : class { - if (!PathUtilities.IsSafeRelativePath(path)) - throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{nameof(this.WriteJsonFile)} with a relative path."); + this.AssertRelativePath(path, nameof(this.WriteJsonFile)); - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.DirectoryPath, PathUtilities.NormalizePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, data); } @@ -95,5 +107,17 @@ namespace StardewModdingAPI.Framework return this.Content.GetActualAssetKey(key, ContentSource.ModFolder); } + + /********* + ** Private methods + *********/ + /// <summary>Assert that a relative path was passed it to a content pack method.</summary> + /// <param name="path">The path to check.</param> + /// <param name="methodName">The name of the method which was invoked.</param> + private void AssertRelativePath(string path, string methodName) + { + if (!PathUtilities.IsSafeRelativePath(path)) + throw new InvalidOperationException($"You must call {nameof(IContentPack)}.{methodName} with a relative path."); + } } } diff --git a/src/SMAPI/Framework/CursorPosition.cs b/src/SMAPI/Framework/CursorPosition.cs index 079917f2..2008ccce 100644 --- a/src/SMAPI/Framework/CursorPosition.cs +++ b/src/SMAPI/Framework/CursorPosition.cs @@ -8,10 +8,10 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>The pixel position relative to the top-left corner of the in-game map.</summary> + /// <summary>The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</summary> public Vector2 AbsolutePixels { get; } - /// <summary>The pixel position relative to the top-left corner of the visible screen.</summary> + /// <summary>The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</summary> public Vector2 ScreenPixels { get; } /// <summary>The tile position under the cursor relative to the top-left corner of the map.</summary> @@ -25,8 +25,8 @@ namespace StardewModdingAPI.Framework ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map.</param> - /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen.</param> + /// <param name="absolutePixels">The pixel position relative to the top-left corner of the in-game map, adjusted for pixel zoom.</param> + /// <param name="screenPixels">The pixel position relative to the top-left corner of the visible screen, adjusted for pixel zoom.</param> /// <param name="tile">The tile position relative to the top-left corner of the map.</param> /// <param name="grabTile">The tile position that the game considers under the cursor for purposes of clicking actions.</param> public CursorPosition(Vector2 absolutePixels, Vector2 screenPixels, Vector2 tile, Vector2 grabTile) diff --git a/src/SMAPI/Framework/DeprecationManager.cs b/src/SMAPI/Framework/DeprecationManager.cs index 984bb487..636b1979 100644 --- a/src/SMAPI/Framework/DeprecationManager.cs +++ b/src/SMAPI/Framework/DeprecationManager.cs @@ -14,11 +14,7 @@ namespace StardewModdingAPI.Framework private readonly HashSet<string> LoggedDeprecations = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); /// <summary>Encapsulates monitoring and logging for a given module.</summary> -#if !SMAPI_3_0_STRICT - private readonly Monitor Monitor; -#else private readonly IMonitor Monitor; -#endif /// <summary>Tracks the installed mods.</summary> private readonly ModRegistry ModRegistry; @@ -26,11 +22,6 @@ namespace StardewModdingAPI.Framework /// <summary>The queued deprecation warnings to display.</summary> private readonly IList<DeprecationWarning> QueuedWarnings = new List<DeprecationWarning>(); -#if !SMAPI_3_0_STRICT - /// <summary>Whether the one-time deprecation message has been shown.</summary> - private bool DeprecationHeaderShown = false; -#endif - /********* ** Public methods @@ -38,11 +29,7 @@ namespace StardewModdingAPI.Framework /// <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> -#if !SMAPI_3_0_STRICT - public DeprecationManager(Monitor monitor, ModRegistry modRegistry) -#else public DeprecationManager(IMonitor monitor, ModRegistry modRegistry) -#endif { this.Monitor = monitor; this.ModRegistry = modRegistry; @@ -81,26 +68,10 @@ namespace StardewModdingAPI.Framework /// <summary>Print any queued messages.</summary> public void PrintQueued() { -#if !SMAPI_3_0_STRICT - if (!this.DeprecationHeaderShown && this.QueuedWarnings.Any()) - { - this.Monitor.Newline(); - this.Monitor.Log("Some of your mods will break in the upcoming SMAPI 3.0. Please update your mods now, or notify the author if no update is available. See https://mods.smapi.io for links to the latest versions.", LogLevel.Warn); - this.Monitor.Newline(); - this.DeprecationHeaderShown = true; - } -#endif - foreach (DeprecationWarning warning in this.QueuedWarnings.OrderBy(p => p.ModName).ThenBy(p => p.NounPhrase)) { // build message -#if SMAPI_3_0_STRICT string message = $"{warning.ModName} uses deprecated code ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; -#else - string message = warning.NounPhrase == "legacy events" - ? $"{warning.ModName ?? "An unknown mod"} will break in the upcoming SMAPI 3.0 (legacy events are deprecated since SMAPI {warning.Version})." - : $"{warning.ModName ?? "An unknown mod"} will break in the upcoming SMAPI 3.0 ({warning.NounPhrase} is deprecated since SMAPI {warning.Version})."; -#endif // get log level LogLevel level; diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs index 13244601..18b00f69 100644 --- a/src/SMAPI/Framework/Events/EventManager.cs +++ b/src/SMAPI/Framework/Events/EventManager.cs @@ -1,7 +1,4 @@ using System.Diagnostics.CodeAnalysis; -#if !SMAPI_3_0_STRICT -using Microsoft.Xna.Framework.Input; -#endif using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events @@ -76,7 +73,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Raised after the game finishes writing data to the save file (except the initial save creation).</summary> public readonly ManagedEvent<SavedEventArgs> Saved; - /// <summary>Raised after the player loads a save slot and the world is initialised.</summary> + /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> public readonly ManagedEvent<SaveLoadedEventArgs> SaveLoaded; /// <summary>Raised after the game begins a new day, including when loading a save.</summary> @@ -155,208 +152,18 @@ namespace StardewModdingAPI.Framework.Events public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; /**** - ** Specialised + ** Specialized ****/ - /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecialisedEvents.LoadStageChanged"/>.</summary> + /// <summary>Raised when the low-level stage in the game's loading process has changed. See notes on <see cref="ISpecializedEvents.LoadStageChanged"/>.</summary> public readonly ManagedEvent<LoadStageChangedEventArgs> LoadStageChanged; - /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/>.</summary> + /// <summary>Raised before the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/>.</summary> public readonly ManagedEvent<UnvalidatedUpdateTickingEventArgs> UnvalidatedUpdateTicking; - /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/>.</summary> + /// <summary>Raised after the game performs its overall update tick (≈60 times per second). See notes on <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/>.</summary> public readonly ManagedEvent<UnvalidatedUpdateTickedEventArgs> UnvalidatedUpdateTicked; -#if !SMAPI_3_0_STRICT - /********* - ** Events (old) - *********/ - /**** - ** ContentEvents - ****/ - /// <summary>Raised after the content language changes.</summary> - public readonly ManagedEvent<EventArgsValueChanged<string>> Legacy_LocaleChanged; - - /**** - ** ControlEvents - ****/ - /// <summary>Raised when the <see cref="KeyboardState"/> changes. That happens when the player presses or releases a key.</summary> - public readonly ManagedEvent<EventArgsKeyboardStateChanged> Legacy_KeyboardChanged; - - /// <summary>Raised after the player presses a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyPressed; - - /// <summary>Raised after the player releases a keyboard key.</summary> - public readonly ManagedEvent<EventArgsKeyPressed> Legacy_KeyReleased; - - /// <summary>Raised when the <see cref="MouseState"/> changes. That happens when the player moves the mouse, scrolls the mouse wheel, or presses/releases a button.</summary> - public readonly ManagedEvent<EventArgsMouseStateChanged> Legacy_MouseChanged; - - /// <summary>The player pressed a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonPressed> Legacy_ControllerButtonPressed; - - /// <summary>The player released a controller button. This event isn't raised for trigger buttons.</summary> - public readonly ManagedEvent<EventArgsControllerButtonReleased> Legacy_ControllerButtonReleased; - - /// <summary>The player pressed a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerPressed> Legacy_ControllerTriggerPressed; - - /// <summary>The player released a controller trigger button.</summary> - public readonly ManagedEvent<EventArgsControllerTriggerReleased> Legacy_ControllerTriggerReleased; - - /**** - ** GameEvents - ****/ - /// <summary>Raised once after the game initialises and all <see cref="IMod.Entry"/> methods have been called.</summary> - public readonly ManagedEvent Legacy_FirstUpdateTick; - - /// <summary>Raised when the game updates its state (≈60 times per second).</summary> - public readonly ManagedEvent Legacy_UpdateTick; - - /// <summary>Raised every other tick (≈30 times per second).</summary> - public readonly ManagedEvent Legacy_SecondUpdateTick; - - /// <summary>Raised every fourth tick (≈15 times per second).</summary> - public readonly ManagedEvent Legacy_FourthUpdateTick; - - /// <summary>Raised every eighth tick (≈8 times per second).</summary> - public readonly ManagedEvent Legacy_EighthUpdateTick; - - /// <summary>Raised every 15th tick (≈4 times per second).</summary> - public readonly ManagedEvent Legacy_QuarterSecondTick; - - /// <summary>Raised every 30th tick (≈twice per second).</summary> - public readonly ManagedEvent Legacy_HalfSecondTick; - - /// <summary>Raised every 60th tick (≈once per second).</summary> - public readonly ManagedEvent Legacy_OneSecondTick; - - /**** - ** GraphicsEvents - ****/ - /// <summary>Raised after the game window is resized.</summary> - public readonly ManagedEvent Legacy_Resize; - - /// <summary>Raised before drawing the world to the screen.</summary> - public readonly ManagedEvent Legacy_OnPreRenderEvent; - - /// <summary>Raised after drawing the world to the screen.</summary> - public readonly ManagedEvent Legacy_OnPostRenderEvent; - - /// <summary>Raised before drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Legacy_OnPreRenderHudEvent; - - /// <summary>Raised after drawing the HUD (item toolbar, clock, etc) to the screen. The HUD is available at this point, but not necessarily visible. (For example, the event is raised even if a menu is open.)</summary> - public readonly ManagedEvent Legacy_OnPostRenderHudEvent; - - /// <summary>Raised before drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Legacy_OnPreRenderGuiEvent; - - /// <summary>Raised after drawing a menu to the screen during a draw loop. This includes the game's internal menus like the title screen.</summary> - public readonly ManagedEvent Legacy_OnPostRenderGuiEvent; - - /**** - ** InputEvents - ****/ - /// <summary>Raised after the player presses a button on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_ButtonPressed; - - /// <summary>Raised after the player releases a keyboard key on the keyboard, controller, or mouse.</summary> - public readonly ManagedEvent<EventArgsInput> Legacy_ButtonReleased; - - /**** - ** LocationEvents - ****/ - /// <summary>Raised after a game location is added or removed.</summary> - public readonly ManagedEvent<EventArgsLocationsChanged> Legacy_LocationsChanged; - - /// <summary>Raised after buildings are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationBuildingsChanged> Legacy_BuildingsChanged; - - /// <summary>Raised after objects are added or removed in a location.</summary> - public readonly ManagedEvent<EventArgsLocationObjectsChanged> Legacy_ObjectsChanged; - - /**** - ** MenuEvents - ****/ - /// <summary>Raised after a game menu is opened or replaced with another menu. This event is not invoked when a menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuChanged> Legacy_MenuChanged; - - /// <summary>Raised after a game menu is closed.</summary> - public readonly ManagedEvent<EventArgsClickableMenuClosed> Legacy_MenuClosed; - - /**** - ** MultiplayerEvents - ****/ - /// <summary>Raised before the game syncs changes from other players.</summary> - public readonly ManagedEvent Legacy_BeforeMainSync; - - /// <summary>Raised after the game syncs changes from other players.</summary> - public readonly ManagedEvent Legacy_AfterMainSync; - - /// <summary>Raised before the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Legacy_BeforeMainBroadcast; - - /// <summary>Raised after the game broadcasts changes to other players.</summary> - public readonly ManagedEvent Legacy_AfterMainBroadcast; - - /**** - ** MineEvents - ****/ - /// <summary>Raised after the player warps to a new level of the mine.</summary> - public readonly ManagedEvent<EventArgsMineLevelChanged> Legacy_MineLevelChanged; - - /**** - ** PlayerEvents - ****/ - /// <summary>Raised after the player's inventory changes in any way (added or removed item, sorted, etc).</summary> - public readonly ManagedEvent<EventArgsInventoryChanged> Legacy_InventoryChanged; - - /// <summary> Raised after the player levels up a skill. This happens as soon as they level up, not when the game notifies the player after their character goes to bed.</summary> - public readonly ManagedEvent<EventArgsLevelUp> Legacy_LeveledUp; - - /// <summary>Raised after the player warps to a new location.</summary> - public readonly ManagedEvent<EventArgsPlayerWarped> Legacy_PlayerWarped; - - - /**** - ** SaveEvents - ****/ - /// <summary>Raised before the game creates the save file.</summary> - public readonly ManagedEvent Legacy_BeforeCreateSave; - - /// <summary>Raised after the game finishes creating the save file.</summary> - public readonly ManagedEvent Legacy_AfterCreateSave; - - /// <summary>Raised before the game begins writes data to the save file.</summary> - public readonly ManagedEvent Legacy_BeforeSave; - - /// <summary>Raised after the game finishes writing data to the save file.</summary> - public readonly ManagedEvent Legacy_AfterSave; - - /// <summary>Raised after the player loads a save slot.</summary> - public readonly ManagedEvent Legacy_AfterLoad; - - /// <summary>Raised after the game returns to the title screen.</summary> - public readonly ManagedEvent Legacy_AfterReturnToTitle; - - /**** - ** SpecialisedEvents - ****/ - /// <summary>Raised when the game updates its state (≈60 times per second), regardless of normal SMAPI validation. This event is not thread-safe and may be invoked while game logic is running asynchronously. Changes to game state in this method may crash the game or corrupt an in-progress save. Do not use this event unless you're fully aware of the context in which your code will be run. Mods using this method will trigger a stability warning in the SMAPI console.</summary> - public readonly ManagedEvent Legacy_UnvalidatedUpdateTick; - - /**** - ** TimeEvents - ****/ - /// <summary>Raised after the game begins a new day, including when loading a save.</summary> - public readonly ManagedEvent Legacy_AfterDayStarted; - - /// <summary>Raised after the in-game clock changes.</summary> - public readonly ManagedEvent<EventArgsIntChanged> Legacy_TimeOfDayChanged; -#endif - - /********* ** Public methods *********/ @@ -365,11 +172,8 @@ namespace StardewModdingAPI.Framework.Events /// <param name="modRegistry">The mod registry with which to identify mods.</param> public EventManager(IMonitor monitor, ModRegistry modRegistry) { - // create shortcut initialisers + // create shortcut initializers ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry); -#if !SMAPI_3_0_STRICT - ManagedEvent ManageEvent(string typeName, string eventName) => new ManagedEvent($"{typeName}.{eventName}", monitor, modRegistry); -#endif // init events (new) this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged)); @@ -419,73 +223,9 @@ namespace StardewModdingAPI.Framework.Events this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); - this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.LoadStageChanged)); - this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicking)); - this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialised), nameof(ISpecialisedEvents.UnvalidatedUpdateTicked)); - -#if !SMAPI_3_0_STRICT - // init events (old) - this.Legacy_LocaleChanged = ManageEventOf<EventArgsValueChanged<string>>(nameof(ContentEvents), nameof(ContentEvents.AfterLocaleChanged)); - - this.Legacy_ControllerButtonPressed = ManageEventOf<EventArgsControllerButtonPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonPressed)); - this.Legacy_ControllerButtonReleased = ManageEventOf<EventArgsControllerButtonReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerButtonReleased)); - this.Legacy_ControllerTriggerPressed = ManageEventOf<EventArgsControllerTriggerPressed>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerPressed)); - this.Legacy_ControllerTriggerReleased = ManageEventOf<EventArgsControllerTriggerReleased>(nameof(ControlEvents), nameof(ControlEvents.ControllerTriggerReleased)); - this.Legacy_KeyboardChanged = ManageEventOf<EventArgsKeyboardStateChanged>(nameof(ControlEvents), nameof(ControlEvents.KeyboardChanged)); - this.Legacy_KeyPressed = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyPressed)); - this.Legacy_KeyReleased = ManageEventOf<EventArgsKeyPressed>(nameof(ControlEvents), nameof(ControlEvents.KeyReleased)); - this.Legacy_MouseChanged = ManageEventOf<EventArgsMouseStateChanged>(nameof(ControlEvents), nameof(ControlEvents.MouseChanged)); - - this.Legacy_FirstUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FirstUpdateTick)); - this.Legacy_UpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.UpdateTick)); - this.Legacy_SecondUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.SecondUpdateTick)); - this.Legacy_FourthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.FourthUpdateTick)); - this.Legacy_EighthUpdateTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.EighthUpdateTick)); - this.Legacy_QuarterSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.QuarterSecondTick)); - this.Legacy_HalfSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.HalfSecondTick)); - this.Legacy_OneSecondTick = ManageEvent(nameof(GameEvents), nameof(GameEvents.OneSecondTick)); - - this.Legacy_Resize = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.Resize)); - this.Legacy_OnPreRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderEvent)); - this.Legacy_OnPostRenderEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderEvent)); - this.Legacy_OnPreRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderHudEvent)); - this.Legacy_OnPostRenderHudEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderHudEvent)); - this.Legacy_OnPreRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPreRenderGuiEvent)); - this.Legacy_OnPostRenderGuiEvent = ManageEvent(nameof(GraphicsEvents), nameof(GraphicsEvents.OnPostRenderGuiEvent)); - - this.Legacy_ButtonPressed = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonPressed)); - this.Legacy_ButtonReleased = ManageEventOf<EventArgsInput>(nameof(InputEvents), nameof(InputEvents.ButtonReleased)); - - this.Legacy_LocationsChanged = ManageEventOf<EventArgsLocationsChanged>(nameof(LocationEvents), nameof(LocationEvents.LocationsChanged)); - this.Legacy_BuildingsChanged = ManageEventOf<EventArgsLocationBuildingsChanged>(nameof(LocationEvents), nameof(LocationEvents.BuildingsChanged)); - this.Legacy_ObjectsChanged = ManageEventOf<EventArgsLocationObjectsChanged>(nameof(LocationEvents), nameof(LocationEvents.ObjectsChanged)); - - this.Legacy_MenuChanged = ManageEventOf<EventArgsClickableMenuChanged>(nameof(MenuEvents), nameof(MenuEvents.MenuChanged)); - this.Legacy_MenuClosed = ManageEventOf<EventArgsClickableMenuClosed>(nameof(MenuEvents), nameof(MenuEvents.MenuClosed)); - - this.Legacy_BeforeMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainBroadcast)); - this.Legacy_AfterMainBroadcast = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainBroadcast)); - this.Legacy_BeforeMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.BeforeMainSync)); - this.Legacy_AfterMainSync = ManageEvent(nameof(MultiplayerEvents), nameof(MultiplayerEvents.AfterMainSync)); - - this.Legacy_MineLevelChanged = ManageEventOf<EventArgsMineLevelChanged>(nameof(MineEvents), nameof(MineEvents.MineLevelChanged)); - - this.Legacy_InventoryChanged = ManageEventOf<EventArgsInventoryChanged>(nameof(PlayerEvents), nameof(PlayerEvents.InventoryChanged)); - this.Legacy_LeveledUp = ManageEventOf<EventArgsLevelUp>(nameof(PlayerEvents), nameof(PlayerEvents.LeveledUp)); - this.Legacy_PlayerWarped = ManageEventOf<EventArgsPlayerWarped>(nameof(PlayerEvents), nameof(PlayerEvents.Warped)); - - this.Legacy_BeforeCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeCreate)); - this.Legacy_AfterCreateSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterCreate)); - this.Legacy_BeforeSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.BeforeSave)); - this.Legacy_AfterSave = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterSave)); - this.Legacy_AfterLoad = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterLoad)); - this.Legacy_AfterReturnToTitle = ManageEvent(nameof(SaveEvents), nameof(SaveEvents.AfterReturnToTitle)); - - this.Legacy_UnvalidatedUpdateTick = ManageEvent(nameof(SpecialisedEvents), nameof(SpecialisedEvents.UnvalidatedUpdateTick)); - - this.Legacy_AfterDayStarted = ManageEvent(nameof(TimeEvents), nameof(TimeEvents.AfterDayStarted)); - this.Legacy_TimeOfDayChanged = ManageEventOf<EventArgsIntChanged>(nameof(TimeEvents), nameof(TimeEvents.TimeOfDayChanged)); -#endif + this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); + this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking)); + this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked)); } } } diff --git a/src/SMAPI/Framework/Events/ManagedEvent.cs b/src/SMAPI/Framework/Events/ManagedEvent.cs index f9e7f6ec..2afe7a03 100644 --- a/src/SMAPI/Framework/Events/ManagedEvent.cs +++ b/src/SMAPI/Framework/Events/ManagedEvent.cs @@ -1,11 +1,12 @@ using System; +using System.Collections.Generic; using System.Linq; namespace StardewModdingAPI.Framework.Events { /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> /// <typeparam name="TEventArgs">The event arguments type.</typeparam> - internal class ManagedEvent<TEventArgs> : ManagedEventBase<EventHandler<TEventArgs>> + internal class ManagedEvent<TEventArgs> { /********* ** Fields @@ -13,6 +14,21 @@ namespace StardewModdingAPI.Framework.Events /// <summary>The underlying event.</summary> private event EventHandler<TEventArgs> Event; + /// <summary>A human-readable name for the event.</summary> + private readonly string EventName; + + /// <summary>Writes messages to the log.</summary> + private readonly IMonitor Monitor; + + /// <summary>The mod registry with which to identify mods.</summary> + protected readonly ModRegistry ModRegistry; + + /// <summary>The display names for the mods which added each delegate.</summary> + private readonly IDictionary<EventHandler<TEventArgs>, IModMetadata> SourceMods = new Dictionary<EventHandler<TEventArgs>, IModMetadata>(); + + /// <summary>The cached invocation list.</summary> + private EventHandler<TEventArgs>[] CachedInvocationList; + /********* ** Public methods @@ -22,7 +38,17 @@ namespace StardewModdingAPI.Framework.Events /// <param name="monitor">Writes messages to the log.</param> /// <param name="modRegistry">The mod registry with which to identify mods.</param> public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) - : base(eventName, monitor, modRegistry) { } + { + this.EventName = eventName; + this.Monitor = monitor; + this.ModRegistry = modRegistry; + } + + /// <summary>Get whether anything is listening to the event.</summary> + public bool HasListeners() + { + return this.CachedInvocationList?.Length > 0; + } /// <summary>Add an event handler.</summary> /// <param name="handler">The event handler.</param> @@ -91,71 +117,50 @@ namespace StardewModdingAPI.Framework.Events } } } - } - -#if !SMAPI_3_0_STRICT - /// <summary>An event wrapper which intercepts and logs errors in handler code.</summary> - internal class ManagedEvent : ManagedEventBase<EventHandler> - { - /********* - ** Fields - *********/ - /// <summary>The underlying event.</summary> - private event EventHandler Event; /********* - ** Public methods + ** Private methods *********/ - /// <summary>Construct an instance.</summary> - /// <param name="eventName">A human-readable name for the event.</param> - /// <param name="monitor">Writes messages to the log.</param> - /// <param name="modRegistry">The mod registry with which to identify mods.</param> - public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry) - : base(eventName, monitor, modRegistry) { } - - /// <summary>Add an event handler.</summary> + /// <summary>Track an event handler.</summary> + /// <param name="mod">The mod which added the handler.</param> /// <param name="handler">The event handler.</param> - public void Add(EventHandler handler) + /// <param name="invocationList">The updated event invocation list.</param> + protected void AddTracking(IModMetadata mod, EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) { - this.Add(handler, this.ModRegistry.GetFromStack()); + this.SourceMods[handler] = mod; + this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; } - /// <summary>Add an event handler.</summary> + /// <summary>Remove tracking for an event handler.</summary> /// <param name="handler">The event handler.</param> - /// <param name="mod">The mod which added the event handler.</param> - public void Add(EventHandler handler, IModMetadata mod) + /// <param name="invocationList">The updated event invocation list.</param> + protected void RemoveTracking(EventHandler<TEventArgs> handler, IEnumerable<EventHandler<TEventArgs>> invocationList) { - this.Event += handler; - this.AddTracking(mod, handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + this.CachedInvocationList = invocationList?.ToArray() ?? new EventHandler<TEventArgs>[0]; + if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once) + this.SourceMods.Remove(handler); } - /// <summary>Remove an event handler.</summary> + /// <summary>Get the mod which registered the given event handler, if available.</summary> /// <param name="handler">The event handler.</param> - public void Remove(EventHandler handler) + protected IModMetadata GetSourceMod(EventHandler<TEventArgs> handler) { - this.Event -= handler; - this.RemoveTracking(handler, this.Event?.GetInvocationList().Cast<EventHandler>()); + return this.SourceMods.TryGetValue(handler, out IModMetadata mod) + ? mod + : null; } - /// <summary>Raise the event and notify all handlers.</summary> - public void Raise() + /// <summary>Log an exception from an event handler.</summary> + /// <param name="handler">The event handler instance.</param> + /// <param name="ex">The exception that was raised.</param> + protected void LogError(EventHandler<TEventArgs> handler, Exception ex) { - if (this.Event == null) - return; - - foreach (EventHandler handler in this.CachedInvocationList) - { - try - { - handler.Invoke(null, EventArgs.Empty); - } - catch (Exception ex) - { - this.LogError(handler, ex); - } - } + IModMetadata mod = this.GetSourceMod(handler); + if (mod != null) + mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); + else + this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); } } -#endif } diff --git a/src/SMAPI/Framework/Events/ManagedEventBase.cs b/src/SMAPI/Framework/Events/ManagedEventBase.cs deleted file mode 100644 index c8c3516b..00000000 --- a/src/SMAPI/Framework/Events/ManagedEventBase.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace StardewModdingAPI.Framework.Events -{ - /// <summary>The base implementation for an event wrapper which intercepts and logs errors in handler code.</summary> - internal abstract class ManagedEventBase<TEventHandler> - { - /********* - ** Fields - *********/ - /// <summary>A human-readable name for the event.</summary> - private readonly string EventName; - - /// <summary>Writes messages to the log.</summary> - private readonly IMonitor Monitor; - - /// <summary>The mod registry with which to identify mods.</summary> - protected readonly ModRegistry ModRegistry; - - /// <summary>The display names for the mods which added each delegate.</summary> - private readonly IDictionary<TEventHandler, IModMetadata> SourceMods = new Dictionary<TEventHandler, IModMetadata>(); - - /// <summary>The cached invocation list.</summary> - protected TEventHandler[] CachedInvocationList { get; private set; } - - - /********* - ** Public methods - *********/ - /// <summary>Get whether anything is listening to the event.</summary> - public bool HasListeners() - { - return this.CachedInvocationList?.Length > 0; - } - - /********* - ** Protected methods - *********/ - /// <summary>Construct an instance.</summary> - /// <param name="eventName">A human-readable name for the event.</param> - /// <param name="monitor">Writes messages to the log.</param> - /// <param name="modRegistry">The mod registry with which to identify mods.</param> - protected ManagedEventBase(string eventName, IMonitor monitor, ModRegistry modRegistry) - { - this.EventName = eventName; - this.Monitor = monitor; - this.ModRegistry = modRegistry; - } - - /// <summary>Track an event handler.</summary> - /// <param name="mod">The mod which added the handler.</param> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void AddTracking(IModMetadata mod, TEventHandler handler, IEnumerable<TEventHandler> invocationList) - { - this.SourceMods[handler] = mod; - this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0]; - } - - /// <summary>Remove tracking for an event handler.</summary> - /// <param name="handler">The event handler.</param> - /// <param name="invocationList">The updated event invocation list.</param> - protected void RemoveTracking(TEventHandler handler, IEnumerable<TEventHandler> invocationList) - { - this.CachedInvocationList = invocationList?.ToArray() ?? new TEventHandler[0]; - if (!this.CachedInvocationList.Contains(handler)) // don't remove if there's still a reference to the removed handler (e.g. it was added twice and removed once) - this.SourceMods.Remove(handler); - } - - /// <summary>Get the mod which registered the given event handler, if available.</summary> - /// <param name="handler">The event handler.</param> - protected IModMetadata GetSourceMod(TEventHandler handler) - { - return this.SourceMods.TryGetValue(handler, out IModMetadata mod) - ? mod - : null; - } - - /// <summary>Log an exception from an event handler.</summary> - /// <param name="handler">The event handler instance.</param> - /// <param name="ex">The exception that was raised.</param> - protected void LogError(TEventHandler handler, Exception ex) - { - IModMetadata mod = this.GetSourceMod(handler); - if (mod != null) - mod.LogAsMod($"This mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); - else - this.Monitor.Log($"A mod failed in the {this.EventName} event. Technical details: \n{ex.GetLogSummary()}", LogLevel.Error); - } - } -} diff --git a/src/SMAPI/Framework/Events/ModEvents.cs b/src/SMAPI/Framework/Events/ModEvents.cs index 8ad3936c..1d1c92c6 100644 --- a/src/SMAPI/Framework/Events/ModEvents.cs +++ b/src/SMAPI/Framework/Events/ModEvents.cs @@ -26,8 +26,8 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Events raised when something changes in the world.</summary> public IWorldEvents World { get; } - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - public ISpecialisedEvents Specialised { get; } + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + public ISpecializedEvents Specialized { get; } /********* @@ -44,7 +44,7 @@ namespace StardewModdingAPI.Framework.Events this.Multiplayer = new ModMultiplayerEvents(mod, eventManager); this.Player = new ModPlayerEvents(mod, eventManager); this.World = new ModWorldEvents(mod, eventManager); - this.Specialised = new ModSpecialisedEvents(mod, eventManager); + this.Specialized = new ModSpecializedEvents(mod, eventManager); } } } diff --git a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs index 0177c22e..c15460fa 100644 --- a/src/SMAPI/Framework/Events/ModGameLoopEvents.cs +++ b/src/SMAPI/Framework/Events/ModGameLoopEvents.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Framework.Events remove => this.EventManager.Saved.Remove(value); } - /// <summary>Raised after the player loads a save slot and the world is initialised.</summary> + /// <summary>Raised after the player loads a save slot and the world is initialized.</summary> public event EventHandler<SaveLoadedEventArgs> SaveLoaded { add => this.EventManager.SaveLoaded.Add(value); diff --git a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs index 7c3e9dee..9388bdb2 100644 --- a/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs +++ b/src/SMAPI/Framework/Events/ModSpecialisedEvents.cs @@ -3,8 +3,8 @@ using StardewModdingAPI.Events; namespace StardewModdingAPI.Framework.Events { - /// <summary>Events serving specialised edge cases that shouldn't be used by most mods.</summary> - internal class ModSpecialisedEvents : ModEventsBase, ISpecialisedEvents + /// <summary>Events serving specialized edge cases that shouldn't be used by most mods.</summary> + internal class ModSpecializedEvents : ModEventsBase, ISpecializedEvents { /********* ** Accessors @@ -37,7 +37,7 @@ namespace StardewModdingAPI.Framework.Events /// <summary>Construct an instance.</summary> /// <param name="mod">The mod which uses this instance.</param> /// <param name="eventManager">The underlying event manager.</param> - internal ModSpecialisedEvents(IModMetadata mod, EventManager eventManager) + internal ModSpecializedEvents(IModMetadata mod, EventManager eventManager) : base(mod, eventManager) { } } } diff --git a/src/SMAPI/Framework/GameVersion.cs b/src/SMAPI/Framework/GameVersion.cs index 261de374..cd88895c 100644 --- a/src/SMAPI/Framework/GameVersion.cs +++ b/src/SMAPI/Framework/GameVersion.cs @@ -12,17 +12,20 @@ namespace StardewModdingAPI.Framework /// <summary>A mapping of game to semantic versions.</summary> private static readonly IDictionary<string, string> VersionMap = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase) { + ["1.0"] = "1.0.0", ["1.01"] = "1.0.1", ["1.02"] = "1.0.2", ["1.03"] = "1.0.3", ["1.04"] = "1.0.4", ["1.05"] = "1.0.5", ["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes. - ["1.051b"] = "1.0.6-prelease2", + ["1.051b"] = "1.0.6-prerelease2", ["1.06"] = "1.0.6", ["1.07"] = "1.0.7", ["1.07a"] = "1.0.8-prerelease1", ["1.08"] = "1.0.8", + ["1.1"] = "1.1.0", + ["1.2"] = "1.2.0", ["1.11"] = "1.1.1" }; diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs index 38514959..6ee7df69 100644 --- a/src/SMAPI/Framework/IModMetadata.cs +++ b/src/SMAPI/Framework/IModMetadata.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; @@ -15,10 +16,13 @@ namespace StardewModdingAPI.Framework /// <summary>The mod's display name.</summary> string DisplayName { get; } - /// <summary>The mod's full directory path.</summary> + /// <summary>The root path containing mods.</summary> + string RootPath { get; } + + /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> string DirectoryPath { get; } - /// <summary>The <see cref="DirectoryPath"/> relative to the game's Mods folder.</summary> + /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> string RelativeDirectoryPath { get; } /// <summary>Metadata about the mod from SMAPI's internal data (if any).</summary> @@ -42,6 +46,9 @@ namespace StardewModdingAPI.Framework /// <summary>The content pack instance (if loaded and <see cref="IModInfo.IsContentPack"/> is true).</summary> IContentPack ContentPack { get; } + /// <summary>The translations for this mod (if loaded).</summary> + TranslationHelper Translations { get; } + /// <summary>Writes messages to the console and log file as this mod.</summary> IMonitor Monitor { get; } @@ -67,12 +74,14 @@ namespace StardewModdingAPI.Framework /// <summary>Set the mod instance.</summary> /// <param name="mod">The mod instance to set.</param> - IModMetadata SetMod(IMod mod); + /// <param name="translations">The translations for this mod (if loaded).</param> + IModMetadata SetMod(IMod mod, TranslationHelper translations); /// <summary>Set the mod instance.</summary> /// <param name="contentPack">The contentPack instance to set.</param> /// <param name="monitor">Writes messages to the console and log file.</param> - IModMetadata SetMod(IContentPack contentPack, IMonitor monitor); + /// <param name="translations">The translations for this mod (if loaded).</param> + IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations); /// <summary>Set the mod-provided API instance.</summary> /// <param name="api">The mod-provided API.</param> @@ -102,5 +111,8 @@ namespace StardewModdingAPI.Framework /// <summary>Get whether the mod has a given warning and it hasn't been suppressed in the <see cref="DataRecord"/>.</summary> /// <param name="warning">The warning to check.</param> bool HasUnsuppressWarning(ModWarning warning); + + /// <summary>Get a relative path which includes the root folder name.</summary> + string GetRelativePathWithRoot(); } } diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index 96a7003a..d69e5604 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -80,12 +80,14 @@ namespace StardewModdingAPI.Framework.Input { try { + float zoomMultiplier = (1f / Game1.options.zoomLevel); + // get new states GamePadState realController = GamePad.GetState(PlayerIndex.One); KeyboardState realKeyboard = Keyboard.GetState(); MouseState realMouse = Mouse.GetState(); var activeButtons = this.DeriveStatuses(this.ActiveButtons, realKeyboard, realMouse, realController); - Vector2 cursorAbsolutePos = new Vector2(realMouse.X + Game1.viewport.X, realMouse.Y + Game1.viewport.Y); + Vector2 cursorAbsolutePos = new Vector2((realMouse.X * zoomMultiplier) + Game1.viewport.X, (realMouse.Y * zoomMultiplier) + Game1.viewport.Y); Vector2? playerTilePos = Context.IsPlayerFree ? Game1.player.getTileLocation() : (Vector2?)null; // update real states @@ -94,7 +96,10 @@ namespace StardewModdingAPI.Framework.Input this.RealKeyboard = realKeyboard; this.RealMouse = realMouse; if (cursorAbsolutePos != this.CursorPositionImpl?.AbsolutePixels || playerTilePos != this.LastPlayerTile) - this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos); + { + this.LastPlayerTile = playerTilePos; + this.CursorPositionImpl = this.GetCursorPosition(realMouse, cursorAbsolutePos, zoomMultiplier); + } // update suppressed states this.SuppressButtons.RemoveWhere(p => !this.GetStatus(activeButtons, p).IsDown()); @@ -167,11 +172,11 @@ namespace StardewModdingAPI.Framework.Input *********/ /// <summary>Get the current cursor position.</summary> /// <param name="mouseState">The current mouse state.</param> - /// <param name="absolutePixels">The absolute pixel position relative to the map.</param> - private CursorPosition GetCursorPosition(MouseState mouseState, Vector2 absolutePixels) + /// <param name="absolutePixels">The absolute pixel position relative to the map, adjusted for pixel zoom.</param> + /// <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 rawPixels = new Vector2(mouseState.X, mouseState.Y); - Vector2 screenPixels = rawPixels * new Vector2((float)1.0 / Game1.options.zoomLevel); // derived from Game1::getMouseX + 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 grabTile = (Game1.mouseCursorTransparency > 0 && Utility.tileWithinRadiusOfPlayer((int)tile.X, (int)tile.Y, 1, Game1.player)) // derived from Game1.pressActionButton ? tile diff --git a/src/SMAPI/Framework/InternalExtensions.cs b/src/SMAPI/Framework/InternalExtensions.cs index f52bfe2b..c3155b1c 100644 --- a/src/SMAPI/Framework/InternalExtensions.cs +++ b/src/SMAPI/Framework/InternalExtensions.cs @@ -55,7 +55,7 @@ namespace StardewModdingAPI.Framework ** Exceptions ****/ /// <summary>Get a string representation of an exception suitable for writing to the error log.</summary> - /// <param name="exception">The error to summarise.</param> + /// <param name="exception">The error to summarize.</param> public static string GetLogSummary(this Exception exception) { switch (exception) diff --git a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs index 8b86fdeb..043ae376 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentHelper.cs @@ -9,11 +9,8 @@ using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using xTile; -using xTile.Format; -using xTile.Tiles; namespace StardewModdingAPI.Framework.ModHelpers { @@ -30,10 +27,7 @@ namespace StardewModdingAPI.Framework.ModHelpers private readonly IContentManager GameContentManager; /// <summary>A content manager for this mod which manages files from the mod's folder.</summary> - private readonly IContentManager ModContentManager; - - /// <summary>The absolute path to the mod folder.</summary> - private readonly string ModFolderPath; + private readonly ModContentManager ModContentManager; /// <summary>The friendly mod name for use in errors.</summary> private readonly string ModName; @@ -78,8 +72,7 @@ namespace StardewModdingAPI.Framework.ModHelpers { this.ContentCore = contentCore; this.GameContentManager = contentCore.CreateGameContentManager(this.ContentCore.GetManagedAssetPrefix(modID) + ".content"); - this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), rootDirectory: modFolderPath); - this.ModFolderPath = modFolderPath; + this.ModContentManager = contentCore.CreateModContentManager(this.ContentCore.GetManagedAssetPrefix(modID), modFolderPath, this.GameContentManager); this.ModName = modName; this.Monitor = monitor; } @@ -92,49 +85,19 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> public T Load<T>(string key, ContentSource source = ContentSource.ModFolder) { - SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: {reasonPhrase}."); - try { - this.AssertAndNormaliseAssetName(key); + this.AssertAndNormalizeAssetName(key); switch (source) { case ContentSource.GameContent: - return this.GameContentManager.Load<T>(key); + return this.GameContentManager.Load<T>(key, this.CurrentLocaleConstant, useCache: false); case ContentSource.ModFolder: - // get file - FileInfo file = this.GetModFile(key); - if (!file.Exists) - throw GetContentError($"there's no matching file at path '{file.FullName}'."); - string internalKey = this.GetInternalModAssetKey(file); - - // try cache - if (this.ModContentManager.IsLoaded(internalKey)) - return this.ModContentManager.Load<T>(internalKey); - - // fix map tilesheets - if (file.Extension.ToLower() == ".tbin") - { - // validate - if (typeof(T) != typeof(Map)) - throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Map)}'."); - - // fetch & cache - FormatManager formatManager = FormatManager.Instance; - Map map = formatManager.LoadMap(file.FullName); - this.FixCustomTilesheetPaths(map, relativeMapPath: key); - - // inject map - this.ModContentManager.Inject(internalKey, map); - return (T)(object)map; - } - - // load through content manager - return this.ModContentManager.Load<T>(internalKey); + return this.ModContentManager.Load<T>(key, Constants.DefaultLanguage, useCache: false); default: - throw GetContentError($"unknown content source '{source}'."); + throw new SContentLoadException($"{this.ModName} failed loading content asset '{key}' from {source}: unknown content source '{source}'."); } } catch (Exception ex) when (!(ex is SContentLoadException)) @@ -143,12 +106,12 @@ namespace StardewModdingAPI.Framework.ModHelpers } } - /// <summary>Normalise 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> + /// <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> [Pure] - public string NormaliseAssetName(string assetName) + public string NormalizeAssetName(string assetName) { - return this.ModContentManager.AssertAndNormaliseAssetName(assetName); + return this.ModContentManager.AssertAndNormalizeAssetName(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> @@ -160,11 +123,10 @@ namespace StardewModdingAPI.Framework.ModHelpers switch (source) { case ContentSource.GameContent: - return this.GameContentManager.AssertAndNormaliseAssetName(key); + return this.GameContentManager.AssertAndNormalizeAssetName(key); case ContentSource.ModFolder: - FileInfo file = this.GetModFile(key); - return this.GetInternalModAssetKey(file); + return this.ModContentManager.GetInternalAssetKey(key); default: throw new NotSupportedException($"Unknown content source '{source}'."); @@ -200,6 +162,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentCore.InvalidateCache(predicate).Any(); } + /********* ** Private methods *********/ @@ -207,175 +170,11 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="key">The asset key to check.</param> /// <exception cref="ArgumentException">The asset key is empty or contains invalid characters.</exception> [SuppressMessage("ReSharper", "ParameterOnlyUsedForPreconditionCheck.Local", Justification = "Parameter is only used for assertion checks by design.")] - private void AssertAndNormaliseAssetName(string key) + private void AssertAndNormalizeAssetName(string key) { - this.ModContentManager.AssertAndNormaliseAssetName(key); + this.ModContentManager.AssertAndNormalizeAssetName(key); if (Path.IsPathRooted(key)) throw new ArgumentException("The asset key must not be an absolute path."); } - - /// <summary>Get the internal key in the content cache for a mod asset.</summary> - /// <param name="modFile">The asset file.</param> - private string GetInternalModAssetKey(FileInfo modFile) - { - string relativePath = PathUtilities.GetRelativePath(this.ModFolderPath, modFile.FullName); - return Path.Combine(this.ModContentManager.Name, relativePath); - } - - /// <summary>Fix custom map tilesheet paths so they can be found by the content manager.</summary> - /// <param name="map">The map whose tilesheets to fix.</param> - /// <param name="relativeMapPath">The relative map path within the mod folder.</param> - /// <exception cref="ContentLoadException">A map tilesheet couldn't be resolved.</exception> - /// <remarks> - /// The game's logic for tilesheets in <see cref="Game1.setGraphicsForSeason"/> is a bit specialised. It boils - /// down to this: - /// * If the location is indoors or the desert, or the image source contains 'path' or 'object', it's loaded - /// as-is relative to the <c>Content</c> folder. - /// * Else it's loaded from <c>Content\Maps</c> with a seasonal prefix. - /// - /// That logic doesn't work well in our case, mainly because we have no location metadata at this point. - /// Instead we use a more heuristic approach: check relative to the map file first, then relative to - /// <c>Content\Maps</c>, then <c>Content</c>. If the image source filename contains a seasonal prefix, try for a - /// seasonal variation and then an exact match. - /// - /// While that doesn't exactly match the game logic, it's close enough that it's unlikely to make a difference. - /// </remarks> - private void FixCustomTilesheetPaths(Map map, string relativeMapPath) - { - // get map info - if (!map.TileSheets.Any()) - return; - relativeMapPath = this.ModContentManager.AssertAndNormaliseAssetName(relativeMapPath); // Mono's Path.GetDirectoryName doesn't handle Windows dir separators - string relativeMapFolder = Path.GetDirectoryName(relativeMapPath) ?? ""; // folder path containing the map, relative to the mod folder - - // fix tilesheets - foreach (TileSheet tilesheet in map.TileSheets) - { - string imageSource = tilesheet.ImageSource; - - // validate tilesheet path - if (Path.IsPathRooted(imageSource) || PathUtilities.GetSegments(imageSource).Contains("..")) - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded. Tilesheet paths must be a relative path without directory climbing (../)."); - - // get seasonal name (if applicable) - string seasonalImageSource = null; - if (Context.IsSaveLoaded && Game1.currentSeason != null) - { - string filename = Path.GetFileName(imageSource) ?? throw new InvalidOperationException($"The '{imageSource}' tilesheet couldn't be loaded: filename is unexpectedly null."); - bool hasSeasonalPrefix = - filename.StartsWith("spring_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("summer_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("fall_", StringComparison.CurrentCultureIgnoreCase) - || filename.StartsWith("winter_", StringComparison.CurrentCultureIgnoreCase); - if (hasSeasonalPrefix && !filename.StartsWith(Game1.currentSeason + "_")) - { - string dirPath = imageSource.Substring(0, imageSource.LastIndexOf(filename, StringComparison.CurrentCultureIgnoreCase)); - seasonalImageSource = $"{dirPath}{Game1.currentSeason}_{filename.Substring(filename.IndexOf("_", StringComparison.CurrentCultureIgnoreCase) + 1)}"; - } - } - - // load best match - try - { - string key = - this.GetTilesheetAssetName(relativeMapFolder, seasonalImageSource) - ?? this.GetTilesheetAssetName(relativeMapFolder, imageSource); - if (key != null) - { - tilesheet.ImageSource = key; - continue; - } - } - catch (Exception ex) - { - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder.", ex); - } - - // none found - throw new ContentLoadException($"The '{imageSource}' tilesheet couldn't be loaded relative to either map file or the game's content folder."); - } - } - - /// <summary>Get the actual asset name for a tilesheet.</summary> - /// <param name="modRelativeMapFolder">The folder path containing the map, relative to the mod folder.</param> - /// <param name="imageSource">The tilesheet image source to load.</param> - /// <returns>Returns the asset name.</returns> - /// <remarks>See remarks on <see cref="FixCustomTilesheetPaths"/>.</remarks> - private string GetTilesheetAssetName(string modRelativeMapFolder, string imageSource) - { - if (imageSource == null) - return null; - - // check relative to map file - { - string localKey = Path.Combine(modRelativeMapFolder, imageSource); - FileInfo localFile = this.GetModFile(localKey); - if (localFile.Exists) - return this.GetActualAssetKey(localKey); - } - - // check relative to content folder - { - foreach (string candidateKey in new[] { imageSource, Path.Combine("Maps", imageSource) }) - { - string contentKey = candidateKey.EndsWith(".png") - ? candidateKey.Substring(0, candidateKey.Length - 4) - : candidateKey; - - try - { - this.Load<Texture2D>(contentKey, ContentSource.GameContent); - return contentKey; - } - catch - { - // ignore file-not-found errors - // TODO: while it's useful to suppress an asset-not-found error here to avoid - // confusion, this is a pretty naive approach. Even if the file doesn't exist, - // the file may have been loaded through an IAssetLoader which failed. So even - // if the content file doesn't exist, that doesn't mean the error here is a - // content-not-found error. Unfortunately XNA doesn't provide a good way to - // detect the error type. - if (this.GetContentFolderFile(contentKey).Exists) - throw; - } - } - } - - // not found - return null; - } - - /// <summary>Get a file from the mod folder.</summary> - /// <param name="path">The asset path relative to the mod folder.</param> - private FileInfo GetModFile(string path) - { - // try exact match - path = Path.Combine(this.ModFolderPath, this.ModContentManager.NormalisePathSeparators(path)); - FileInfo file = new FileInfo(path); - - // try with default extension - if (!file.Exists && file.Extension.ToLower() != ".xnb") - { - FileInfo result = new FileInfo(path + ".xnb"); - if (result.Exists) - file = result; - } - - return file; - } - - /// <summary>Get a file from the game's content folder.</summary> - /// <param name="key">The asset key.</param> - private FileInfo GetContentFolderFile(string key) - { - // get file path - string path = Path.Combine(this.GameContentManager.FullRootDirectory, key); - if (!path.EndsWith(".xnb")) - path += ".xnb"; - - // get file - return new FileInfo(path); - } } } diff --git a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs index 34f24d65..acdd82a0 100644 --- a/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ContentPackHelper.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; using System.IO; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; namespace StardewModdingAPI.Framework.ModHelpers { @@ -38,7 +38,7 @@ namespace StardewModdingAPI.Framework.ModHelpers return this.ContentPacks.Value; } - /// <summary>Create a temporary content pack to read files from a directory, using randomised manifest fields. This will generate fake manifest data; any <c>manifest.json</c> in the directory will be ignored. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> + /// <summary>Create a temporary content pack to read files from a directory, using randomized manifest fields. This will generate fake manifest data; any <c>manifest.json</c> in the directory will be ignored. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> public IContentPack CreateFake(string directoryPath) { diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index 3b5c1752..cc08c42b 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -1,7 +1,7 @@ using System; using System.IO; using Newtonsoft.Json; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -40,14 +40,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Read data from a JSON file in the mod's folder.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the mod folder.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <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> public TModel ReadJsonFile<TModel>(string path) where TModel : class { if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IModHelper.Data)}.{nameof(this.ReadJsonFile)} with a relative path."); - path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePathSeparators(path)); return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) ? data : null; @@ -63,7 +63,7 @@ namespace StardewModdingAPI.Framework.ModHelpers if (!PathUtilities.IsSafeRelativePath(path)) throw new InvalidOperationException($"You must call {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteJsonFile)} with a relative path (without directory climbing)."); - path = Path.Combine(this.ModFolderPath, PathUtilities.NormalisePathSeparators(path)); + path = Path.Combine(this.ModFolderPath, PathUtilities.NormalizePathSeparators(path)); this.JsonHelper.WriteJsonFile(path, data); } @@ -83,7 +83,7 @@ namespace StardewModdingAPI.Framework.ModHelpers throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value) - ? this.JsonHelper.Deserialise<TModel>(value) + ? this.JsonHelper.Deserialize<TModel>(value) : null; } @@ -101,7 +101,7 @@ namespace StardewModdingAPI.Framework.ModHelpers string internalKey = this.GetSaveFileKey(key); if (data != null) - Game1.CustomData[internalKey] = this.JsonHelper.Serialise(data, Formatting.None); + Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None); else Game1.CustomData.Remove(internalKey); } diff --git a/src/SMAPI/Framework/ModHelpers/ModHelper.cs b/src/SMAPI/Framework/ModHelpers/ModHelper.cs index 6c9838c9..25401e23 100644 --- a/src/SMAPI/Framework/ModHelpers/ModHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModHelper.cs @@ -1,11 +1,8 @@ using System; -using System.Collections.Generic; using System.IO; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Input; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Models; -using StardewModdingAPI.Toolkit.Utilities; +using StardewModdingAPI.Toolkit.Serialization; namespace StardewModdingAPI.Framework.ModHelpers { @@ -18,11 +15,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>The full path to the mod's folder.</summary> public string DirectoryPath { get; } -#if !SMAPI_3_0_STRICT - /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> - private readonly JsonHelper JsonHelper; -#endif - /// <summary>Manages access to events raised by SMAPI, which let your mod react when something happens in the game.</summary> public IModEvents Events { get; } @@ -60,7 +52,6 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <summary>Construct an instance.</summary> /// <param name="modID">The mod's unique ID.</param> /// <param name="modDirectory">The full path to the mod's folder.</param> - /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> /// <param name="inputState">Manages the game's input state.</param> /// <param name="events">Manages access to events raised by SMAPI.</param> /// <param name="contentHelper">An API for loading content assets.</param> @@ -73,7 +64,7 @@ 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, JsonHelper jsonHelper, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) + public ModHelper(string modID, string modDirectory, SInputState inputState, IModEvents events, IContentHelper contentHelper, IContentPackHelper contentPackHelper, ICommandHelper commandHelper, IDataHelper dataHelper, IModRegistry modRegistry, IReflectionHelper reflectionHelper, IMultiplayerHelper multiplayer, ITranslationHelper translationHelper) : base(modID) { // validate directory @@ -82,7 +73,7 @@ namespace StardewModdingAPI.Framework.ModHelpers if (!Directory.Exists(modDirectory)) throw new InvalidOperationException("The specified mod directory does not exist."); - // initialise + // initialize this.DirectoryPath = modDirectory; this.Content = contentHelper ?? throw new ArgumentNullException(nameof(contentHelper)); this.ContentPacks = contentPackHelper ?? throw new ArgumentNullException(nameof(contentPackHelper)); @@ -94,9 +85,6 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Multiplayer = multiplayer ?? throw new ArgumentNullException(nameof(multiplayer)); this.Translation = translationHelper ?? throw new ArgumentNullException(nameof(translationHelper)); this.Events = events; -#if !SMAPI_3_0_STRICT - this.JsonHelper = jsonHelper ?? throw new ArgumentNullException(nameof(jsonHelper)); -#endif } /**** @@ -121,63 +109,6 @@ namespace StardewModdingAPI.Framework.ModHelpers this.Data.WriteJsonFile("config.json", config); } -#if !SMAPI_3_0_STRICT - /**** - ** Generic JSON files - ****/ - /// <summary>Read a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> - [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] - public TModel ReadJsonFile<TModel>(string path) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - return this.JsonHelper.ReadJsonFileIfExists(path, out TModel data) - ? data - : null; - } - - /// <summary>Save to a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <param name="model">The model to save.</param> - [Obsolete("Use " + nameof(ModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] - public void WriteJsonFile<TModel>(string path, TModel model) - where TModel : class - { - path = Path.Combine(this.DirectoryPath, PathUtilities.NormalisePathSeparators(path)); - this.JsonHelper.WriteJsonFile(path, model); - } -#endif - - /**** - ** Content packs - ****/ -#if !SMAPI_3_0_STRICT - /// <summary>Manually create a transitional content pack to support pre-SMAPI content packs. This provides a way to access legacy content packs using the SMAPI content pack APIs, but the content pack will not be visible in the log or validated by SMAPI.</summary> - /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> - /// <param name="id">The content pack's unique ID.</param> - /// <param name="name">The content pack name.</param> - /// <param name="description">The content pack description.</param> - /// <param name="author">The content pack author's name.</param> - /// <param name="version">The content pack version.</param> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.CreateTemporary) + " instead")] - public IContentPack CreateTransitionalContentPack(string directoryPath, string id, string name, string description, string author, ISemanticVersion version) - { - SCore.DeprecationManager.Warn($"{nameof(IModHelper)}.{nameof(IModHelper.CreateTransitionalContentPack)}", "2.5", DeprecationLevel.PendingRemoval); - return this.ContentPacks.CreateTemporary(directoryPath, id, name, description, author, version); - } - - /// <summary>Get all content packs loaded for this mod.</summary> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.GetOwned) + " instead")] - public IEnumerable<IContentPack> GetContentPacks() - { - return this.ContentPacks.GetOwned(); - } -#endif - /**** ** Disposal ****/ diff --git a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs index 8330e078..f42cb085 100644 --- a/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ModRegistryHelper.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers @@ -63,6 +62,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <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> public object GetApi(string uniqueID) { + // validate ready + if (!this.Registry.AreAllModsInitialized) + { + this.Monitor.Log("Tried to access a mod-provided API before all mods were initialized.", LogLevel.Error); + return null; + } + + // get raw API 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); @@ -74,12 +81,12 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="uniqueID">The mod's unique ID.</param> public TInterface GetApi<TInterface>(string uniqueID) where TInterface : class { - // validate - if (!this.Registry.AreAllModsInitialised) - { - this.Monitor.Log("Tried to access a mod-provided API before all mods were initialised.", LogLevel.Error); + // get raw API + object api = this.GetApi(uniqueID); + if (api == null) return null; - } + + // validate mapping if (!typeof(TInterface).IsInterface) { this.Monitor.Log($"Tried to map a mod-provided API to class '{typeof(TInterface).FullName}'; must be a public interface.", LogLevel.Error); @@ -91,11 +98,6 @@ namespace StardewModdingAPI.Framework.ModHelpers return null; } - // get raw API - object api = this.GetApi(uniqueID); - if (api == null) - return null; - // get API of type if (api is TInterface castApi) return castApi; diff --git a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs index 0ce72a9e..86c327ed 100644 --- a/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/ReflectionHelper.cs @@ -5,7 +5,7 @@ using StardewModdingAPI.Framework.Reflection; namespace StardewModdingAPI.Framework.ModHelpers { /// <summary>Provides helper methods for accessing private game code.</summary> - /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimize performance without unnecessary memory usage).</remarks> internal class ReflectionHelper : BaseHelper, IReflectionHelper { /********* diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs index 3252e047..be7768e8 100644 --- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -11,24 +9,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Fields *********/ - /// <summary>The name of the relevant mod for error messages.</summary> - private readonly string ModName; - - /// <summary>The translations for each locale.</summary> - private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); - - /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> - private IDictionary<string, Translation> ForLocale; + /// <summary>The underlying translation manager.</summary> + private readonly Translator Translator; /********* ** Accessors *********/ /// <summary>The current locale.</summary> - public string Locale { get; private set; } + public string Locale => this.Translator.Locale; /// <summary>The game's current language code.</summary> - public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + public LocalizedContentManager.LanguageCode LocaleEnum => this.Translator.LocaleEnum; /********* @@ -36,31 +28,26 @@ namespace StardewModdingAPI.Framework.ModHelpers *********/ /// <summary>Construct an instance.</summary> /// <param name="modID">The unique ID of the relevant mod.</param> - /// <param name="modName">The name of the relevant mod for error messages.</param> /// <param name="locale">The initial locale.</param> /// <param name="languageCode">The game's current language code.</param> - public TranslationHelper(string modID, string modName, string locale, LocalizedContentManager.LanguageCode languageCode) + public TranslationHelper(string modID, string locale, LocalizedContentManager.LanguageCode languageCode) : base(modID) { - // save data - this.ModName = modName; - - // set locale - this.SetLocale(locale, languageCode); + this.Translator = new Translator(); + this.Translator.SetLocale(locale, languageCode); } /// <summary>Get all translations for the current locale.</summary> public IEnumerable<Translation> GetTranslations() { - return this.ForLocale.Values.ToArray(); + return this.Translator.GetTranslations(); } /// <summary>Get a translation for the current locale.</summary> /// <param name="key">The translation key.</param> public Translation Get(string key) { - this.ForLocale.TryGetValue(key, out Translation translation); - return translation ?? new Translation(this.ModName, this.Locale, key, null); + return this.Translator.Get(key); } /// <summary>Get a translation for the current locale.</summary> @@ -68,21 +55,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> public Translation Get(string key, object tokens) { - return this.Get(key).Tokens(tokens); + return this.Translator.Get(key, tokens); } /// <summary>Set the translations to use.</summary> /// <param name="translations">The translations to use.</param> internal TranslationHelper SetTranslations(IDictionary<string, IDictionary<string, string>> translations) { - // reset translations - this.All.Clear(); - foreach (var pair in translations) - this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); - - // rebuild cache - this.SetLocale(this.Locale, this.LocaleEnum); - + this.Translator.SetTranslations(translations); return this; } @@ -91,50 +71,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// <param name="localeEnum">The game's current language code.</param> internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) { - this.Locale = locale.ToLower().Trim(); - this.LocaleEnum = localeEnum; - - this.ForLocale = new Dictionary<string, Translation>(StringComparer.InvariantCultureIgnoreCase); - foreach (string next in this.GetRelevantLocales(this.Locale)) - { - // skip if locale not defined - if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) - continue; - - // add missing translations - foreach (var pair in translations) - { - if (!this.ForLocale.ContainsKey(pair.Key)) - this.ForLocale.Add(pair.Key, new Translation(this.ModName, this.Locale, pair.Key, pair.Value)); - } - } - } - - - /********* - ** Private methods - *********/ - /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> - /// <param name="locale">The locale for which to find valid locales.</param> - private IEnumerable<string> GetRelevantLocales(string locale) - { - // given locale - yield return locale; - - // broader locales (like pt-BR => pt) - while (true) - { - int dashIndex = locale.LastIndexOf('-'); - if (dashIndex <= 0) - break; - - locale = locale.Substring(0, dashIndex); - yield return locale; - } - - // default - if (locale != "default") - yield return "default"; + this.Translator.SetLocale(locale, localeEnum); } } } diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs index 878b3148..7670eb3a 100644 --- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs +++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs @@ -6,9 +6,9 @@ using System.Reflection; using Mono.Cecil; using Mono.Cecil.Cil; using StardewModdingAPI.Framework.Exceptions; -using StardewModdingAPI.Internal; using StardewModdingAPI.Metadata; using StardewModdingAPI.Toolkit.Framework.ModData; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { @@ -105,7 +105,7 @@ namespace StardewModdingAPI.Framework.ModLoading continue; // rewrite assembly - bool changed = this.RewriteAssembly(mod, assembly.Definition, assumeCompatible, loggedMessages, logPrefix: " "); + bool changed = this.RewriteAssembly(mod, assembly.Definition, loggedMessages, logPrefix: " "); // detect broken assembly reference foreach (AssemblyNameReference reference in assembly.Definition.MainModule.AssemblyReferences) @@ -114,7 +114,7 @@ namespace StardewModdingAPI.Framework.ModLoading { this.Monitor.LogOnce(loggedMessages, $" Broken code in {assembly.File.Name}: reference to missing assembly '{reference.FullName}'."); if (!assumeCompatible) - throw new IncompatibleInstructionException($"assembly reference to {reference.FullName}", $"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}."); + throw new IncompatibleInstructionException($"Found a reference to missing assembly '{reference.FullName}' while loading assembly {assembly.File.Name}."); mod.SetWarning(ModWarning.BrokenCodeLoaded); break; } @@ -143,6 +143,10 @@ namespace StardewModdingAPI.Framework.ModLoading this.AssemblyDefinitionResolver.Add(assembly.Definition); } + // throw if incompatibilities detected + if (!assumeCompatible && mod.Warnings.HasFlag(ModWarning.BrokenCodeLoaded)) + throw new IncompatibleInstructionException(); + // last assembly loaded is the root return lastAssembly; } @@ -244,12 +248,11 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Rewrite the types referenced by an assembly.</summary> /// <param name="mod">The mod for which the assembly is being loaded.</param> /// <param name="assembly">The assembly to rewrite.</param> - /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> /// <param name="loggedMessages">The messages that have already been logged for this mod.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> /// <returns>Returns whether the assembly was modified.</returns> /// <exception cref="IncompatibleInstructionException">An incompatible CIL instruction was found while rewriting the assembly.</exception> - private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, bool assumeCompatible, HashSet<string> loggedMessages, string logPrefix) + private bool RewriteAssembly(IModMetadata mod, AssemblyDefinition assembly, HashSet<string> loggedMessages, string logPrefix) { ModuleDefinition module = assembly.MainModule; string filename = $"{assembly.Name.Name}.dll"; @@ -288,7 +291,7 @@ namespace StardewModdingAPI.Framework.ModLoading foreach (IInstructionHandler handler in handlers) { InstructionHandleResult result = handler.Handle(module, method, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); if (result == InstructionHandleResult.Rewritten) anyRewritten = true; } @@ -303,7 +306,7 @@ namespace StardewModdingAPI.Framework.ModLoading { Instruction instruction = instructions[offset]; InstructionHandleResult result = handler.Handle(module, cil, instruction, this.AssemblyMap, platformChanged); - this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, assumeCompatible, filename); + this.ProcessInstructionHandleResult(mod, handler, result, loggedMessages, logPrefix, filename); if (result == InstructionHandleResult.Rewritten) anyRewritten = true; } @@ -314,14 +317,13 @@ namespace StardewModdingAPI.Framework.ModLoading } /// <summary>Process the result from an instruction handler.</summary> - /// <param name="mod">The mod being analysed.</param> + /// <param name="mod">The mod being analyzed.</param> /// <param name="handler">The instruction handler.</param> /// <param name="result">The result returned by the handler.</param> /// <param name="loggedMessages">The messages already logged for the current mod.</param> - /// <param name="assumeCompatible">Assume the mod is compatible, even if incompatible code is detected.</param> /// <param name="logPrefix">A string to prefix to log messages.</param> /// <param name="filename">The assembly filename for log messages.</param> - private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, bool assumeCompatible, string filename) + private void ProcessInstructionHandleResult(IModMetadata mod, IInstructionHandler handler, InstructionHandleResult result, HashSet<string> loggedMessages, string logPrefix, string filename) { switch (result) { @@ -331,8 +333,6 @@ namespace StardewModdingAPI.Framework.ModLoading case InstructionHandleResult.NotCompatible: this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Broken code in {filename}: {handler.NounPhrase}."); - if (!assumeCompatible) - throw new IncompatibleInstructionException(handler.NounPhrase, $"Found an incompatible CIL instruction ({handler.NounPhrase}) while loading assembly {filename}."); mod.SetWarning(ModWarning.BrokenCodeLoaded); break; @@ -341,9 +341,9 @@ namespace StardewModdingAPI.Framework.ModLoading mod.SetWarning(ModWarning.PatchesGame); break; - case InstructionHandleResult.DetectedSaveSerialiser: - this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serialiser change ({handler.NounPhrase}) in assembly {filename}."); - mod.SetWarning(ModWarning.ChangesSaveSerialiser); + case InstructionHandleResult.DetectedSaveSerializer: + this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected possible save serializer change ({handler.NounPhrase}) in assembly {filename}."); + mod.SetWarning(ModWarning.ChangesSaveSerializer); break; case InstructionHandleResult.DetectedUnvalidatedUpdateTick: @@ -370,7 +370,7 @@ namespace StardewModdingAPI.Framework.ModLoading break; default: - throw new NotSupportedException($"Unrecognised instruction handler result '{result}'."); + throw new NotSupportedException($"Unrecognized instruction handler result '{result}'."); } } diff --git a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs index 82c4920a..459e3210 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/ReferenceToMemberWithUnexpectedTypeFinder.cs @@ -80,10 +80,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders // compare return types MethodDefinition methodDef = methodReference.Resolve(); if (methodDef == null) - { - this.NounPhrase = $"reference to {methodReference.DeclaringType.FullName}.{methodReference.Name} (no such method)"; - return InstructionHandleResult.NotCompatible; - } + return InstructionHandleResult.None; // validated by ReferenceToMissingMemberFinder if (candidateMethods.All(method => !RewriteHelper.LooksLikeSameType(method.ReturnType, methodDef.ReturnType))) { diff --git a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs index 79045241..701b15f2 100644 --- a/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs +++ b/src/SMAPI/Framework/ModLoading/Finders/TypeFinder.cs @@ -73,7 +73,7 @@ namespace StardewModdingAPI.Framework.ModLoading.Finders ** Protected methods *********/ /// <summary>Get whether a CIL instruction matches.</summary> - /// <param name="method">The method deifnition.</param> + /// <param name="method">The method definition.</param> protected bool IsMatch(MethodDefinition method) { if (this.IsMatch(method.ReturnType)) diff --git a/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs b/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs index 17ec24b1..1f9add30 100644 --- a/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs +++ b/src/SMAPI/Framework/ModLoading/IncompatibleInstructionException.cs @@ -6,30 +6,15 @@ namespace StardewModdingAPI.Framework.ModLoading internal class IncompatibleInstructionException : Exception { /********* - ** Accessors - *********/ - /// <summary>A brief noun phrase which describes the incompatible instruction that was found.</summary> - public string NounPhrase { get; } - - - /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> - /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> - public IncompatibleInstructionException(string nounPhrase) - : base($"Found an incompatible CIL instruction ({nounPhrase}).") - { - this.NounPhrase = nounPhrase; - } + public IncompatibleInstructionException() + : base("Found incompatible CIL instructions.") { } /// <summary>Construct an instance.</summary> - /// <param name="nounPhrase">A brief noun phrase which describes the incompatible instruction that was found.</param> /// <param name="message">A message which describes the error.</param> - public IncompatibleInstructionException(string nounPhrase, string message) - : base(message) - { - this.NounPhrase = nounPhrase; - } + public IncompatibleInstructionException(string message) + : base(message) { } } } diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs index 6592760e..d93b603d 100644 --- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs +++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs @@ -18,12 +18,12 @@ namespace StardewModdingAPI.Framework.ModLoading DetectedGamePatch, /// <summary>The instruction is compatible, but affects the save serializer in a way that may make saves unloadable without the mod.</summary> - DetectedSaveSerialiser, + DetectedSaveSerializer, /// <summary>The instruction is compatible, but uses the <c>dynamic</c> keyword which won't work on Linux/Mac.</summary> DetectedDynamic, - /// <summary>The instruction is compatible, but references <see cref="ISpecialisedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecialisedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> + /// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> DetectedUnvalidatedUpdateTick, /// <summary>The instruction accesses the filesystem directly.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs b/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs index 0774b487..dd855d2f 100644 --- a/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs +++ b/src/SMAPI/Framework/ModLoading/ModDependencyStatus.cs @@ -1,4 +1,4 @@ -namespace StardewModdingAPI.Framework.ModLoading +namespace StardewModdingAPI.Framework.ModLoading { /// <summary>The status of a given mod in the dependency-sorting algorithm.</summary> internal enum ModDependencyStatus @@ -6,7 +6,7 @@ /// <summary>The mod hasn't been visited yet.</summary> Queued, - /// <summary>The mod is currently being analysed as part of a dependency chain.</summary> + /// <summary>The mod is currently being analyzed as part of a dependency chain.</summary> Checking, /// <summary>The mod has already been sorted.</summary> diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs index 4ff021b7..7f788d17 100644 --- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs +++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs @@ -1,9 +1,12 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.UpdateData; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { @@ -16,10 +19,13 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The mod's display name.</summary> public string DisplayName { get; } - /// <summary>The mod's full directory path.</summary> + /// <summary>The root path containing mods.</summary> + public string RootPath { get; } + + /// <summary>The mod's full directory path within the <see cref="RootPath"/>.</summary> public string DirectoryPath { get; } - /// <summary>The <see cref="IModMetadata.DirectoryPath"/> relative to the game's Mods folder.</summary> + /// <summary>The <see cref="DirectoryPath"/> relative to the <see cref="RootPath"/>.</summary> public string RelativeDirectoryPath { get; } /// <summary>The mod manifest.</summary> @@ -46,6 +52,9 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>The content pack instance (if loaded and <see cref="IsContentPack"/> is true).</summary> public IContentPack ContentPack { get; private set; } + /// <summary>The translations for this mod (if loaded).</summary> + public TranslationHelper Translations { get; private set; } + /// <summary>Writes messages to the console and log file as this mod.</summary> public IMonitor Monitor { get; private set; } @@ -64,16 +73,17 @@ namespace StardewModdingAPI.Framework.ModLoading *********/ /// <summary>Construct an instance.</summary> /// <param name="displayName">The mod's display name.</param> - /// <param name="directoryPath">The mod's full directory path.</param> - /// <param name="relativeDirectoryPath">The <paramref name="directoryPath"/> relative to the game's Mods folder.</param> + /// <param name="directoryPath">The mod's full directory path within the <paramref name="rootPath"/>.</param> + /// <param name="rootPath">The root path containing mods.</param> /// <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 relativeDirectoryPath, 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.RelativeDirectoryPath = relativeDirectoryPath; + this.RootPath = rootPath; + this.RelativeDirectoryPath = PathUtilities.GetRelativePath(this.RootPath, this.DirectoryPath); this.Manifest = manifest; this.DataRecord = dataRecord; this.IsIgnored = isIgnored; @@ -100,26 +110,30 @@ namespace StardewModdingAPI.Framework.ModLoading /// <summary>Set the mod instance.</summary> /// <param name="mod">The mod instance to set.</param> - public IModMetadata SetMod(IMod mod) + /// <param name="translations">The translations for this mod (if loaded).</param> + public IModMetadata SetMod(IMod mod, TranslationHelper translations) { if (this.ContentPack != null) throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); this.Mod = mod; this.Monitor = mod.Monitor; + this.Translations = translations; return this; } /// <summary>Set the mod instance.</summary> /// <param name="contentPack">The contentPack instance to set.</param> /// <param name="monitor">Writes messages to the console and log file.</param> - public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor) + /// <param name="translations">The translations for this mod (if loaded).</param> + public IModMetadata SetMod(IContentPack contentPack, IMonitor monitor, TranslationHelper translations) { if (this.Mod != null) throw new InvalidOperationException("A mod can't be both an assembly mod and content pack."); this.ContentPack = contentPack; this.Monitor = monitor; + this.Translations = translations; return this; } @@ -188,5 +202,12 @@ namespace StardewModdingAPI.Framework.ModLoading this.Warnings.HasFlag(warning) && (this.DataRecord?.DataRecord == null || !this.DataRecord.DataRecord.SuppressWarnings.HasFlag(warning)); } + + /// <summary>Get a relative path which includes the root folder name.</summary> + public string GetRelativePathWithRoot() + { + string rootFolderName = Path.GetFileName(this.RootPath) ?? ""; + return Path.Combine(rootFolderName, this.RelativeDirectoryPath); + } } } diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs index 75d3849d..5ea21710 100644 --- a/src/SMAPI/Framework/ModLoading/ModResolver.cs +++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs @@ -5,7 +5,7 @@ using System.Linq; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.ModData; using StardewModdingAPI.Toolkit.Framework.ModScanning; -using StardewModdingAPI.Toolkit.Serialisation.Models; +using StardewModdingAPI.Toolkit.Serialization.Models; using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading @@ -38,13 +38,13 @@ namespace StardewModdingAPI.Framework.ModLoading } // build metadata - ModMetadataStatus status = folder.ManifestParseError == null || !folder.ShouldBeLoaded + bool shouldIgnore = folder.Type == ModType.Ignored; + ModMetadataStatus status = folder.ManifestParseError == ModParseError.None || shouldIgnore ? ModMetadataStatus.Found : ModMetadataStatus.Failed; - string relativePath = PathUtilities.GetRelativePath(rootPath, folder.Directory.FullName); - yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, relativePath, manifest, dataRecord, isIgnored: !folder.ShouldBeLoaded) - .SetStatus(status, !folder.ShouldBeLoaded ? "disabled by dot convention" : folder.ManifestParseError); + yield return new ModMetadata(folder.DisplayName, folder.Directory.FullName, rootPath, manifest, dataRecord, isIgnored: shouldIgnore) + .SetStatus(status, shouldIgnore ? "disabled by dot convention" : folder.ManifestParseErrorText); } } @@ -143,16 +143,12 @@ namespace StardewModdingAPI.Framework.ModLoading continue; } - // invalid capitalisation + // invalid capitalization string actualFilename = new DirectoryInfo(mod.DirectoryPath).GetFiles(mod.Manifest.EntryDll).FirstOrDefault()?.Name; if (actualFilename != mod.Manifest.EntryDll) { -#if SMAPI_3_0_STRICT - mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalisation '{actualFilename}'. The capitalisation must match for crossplatform compatibility."); + mod.SetStatus(ModMetadataStatus.Failed, $"its {nameof(IManifest.EntryDll)} value '{mod.Manifest.EntryDll}' doesn't match the actual file capitalization '{actualFilename}'. The capitalization must match for crossplatform compatibility."); continue; -#else - SCore.DeprecationManager.Warn(mod.DisplayName, $"{nameof(IManifest.EntryDll)} value with case-insensitive capitalisation", "2.11", DeprecationLevel.PendingRemoval); -#endif } } @@ -202,7 +198,14 @@ namespace StardewModdingAPI.Framework.ModLoading { if (mod.Status == ModMetadataStatus.Failed) continue; // don't replace metadata error - mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed ({string.Join(", ", group.Select(p => p.RelativeDirectoryPath).OrderBy(p => p))})."); + + string folderList = string.Join(", ", + from entry in @group + let relativePath = entry.GetRelativePathWithRoot() + orderby relativePath + select $"{relativePath} ({entry.Manifest.Version})" + ); + mod.SetStatus(ModMetadataStatus.Failed, $"you have multiple copies of this mod installed. Found in folders: {folderList}."); } } } @@ -213,7 +216,7 @@ namespace StardewModdingAPI.Framework.ModLoading /// <param name="modDatabase">Handles access to SMAPI's internal mod metadata list.</param> public IEnumerable<IModMetadata> ProcessDependencies(IEnumerable<IModMetadata> mods, ModDatabase modDatabase) { - // initialise metadata + // initialize metadata mods = mods.ToArray(); var sortedMods = new Stack<IModMetadata>(); var states = mods.ToDictionary(mod => mod, mod => ModDependencyStatus.Queued); diff --git a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs index 01460dce..d4366294 100644 --- a/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs +++ b/src/SMAPI/Framework/ModLoading/PlatformAssemblyMap.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using Mono.Cecil; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI.Framework.ModLoading { diff --git a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs index f7497789..a4ac54e2 100644 --- a/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs +++ b/src/SMAPI/Framework/ModLoading/TypeReferenceComparer.cs @@ -54,7 +54,7 @@ namespace StardewModdingAPI.Framework.ModLoading { bool HeuristicallyEquals(string typeNameA, string typeNameB, IDictionary<string, string> tokenMap) { - // analyse type names + // analyze type names bool hasTokensA = typeNameA.Contains("!"); bool hasTokensB = typeNameB.Contains("!"); bool isTokenA = hasTokensA && typeNameA[0] == '!'; diff --git a/src/SMAPI/Framework/ModRegistry.cs b/src/SMAPI/Framework/ModRegistry.cs index 5be33cb4..ef389337 100644 --- a/src/SMAPI/Framework/ModRegistry.cs +++ b/src/SMAPI/Framework/ModRegistry.cs @@ -21,8 +21,8 @@ namespace StardewModdingAPI.Framework /// <summary>Whether all mod assemblies have been loaded.</summary> public bool AreAllModsLoaded { get; set; } - /// <summary>Whether all mods have been initialised and their <see cref="IMod.Entry"/> method called.</summary> - public bool AreAllModsInitialised { get; set; } + /// <summary>Whether all mods have been initialized and their <see cref="IMod.Entry"/> method called.</summary> + public bool AreAllModsInitialized { get; set; } /********* @@ -62,7 +62,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns the matching mod's metadata, or <c>null</c> if not found.</returns> public IModMetadata Get(string uniqueID) { - // normalise search ID + // normalize search ID if (string.IsNullOrWhiteSpace(uniqueID)) return null; uniqueID = uniqueID.Trim(); diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs index e2b33160..b778af5d 100644 --- a/src/SMAPI/Framework/Models/SConfig.cs +++ b/src/SMAPI/Framework/Models/SConfig.cs @@ -1,3 +1,6 @@ +using System; +using System.Collections.Generic; +using System.Linq; using StardewModdingAPI.Internal.ConsoleWriting; namespace StardewModdingAPI.Framework.Models @@ -6,6 +9,35 @@ namespace StardewModdingAPI.Framework.Models internal class SConfig { /******** + ** Fields + ********/ + /// <summary>The default config values, for fields that should be logged if different.</summary> + private static readonly IDictionary<string, object> DefaultValues = new Dictionary<string, object> + { + [nameof(CheckForUpdates)] = true, + [nameof(ParanoidWarnings)] = +#if DEBUG + true, +#else + false, +#endif + [nameof(UseBetaChannel)] = Constants.ApiVersion.IsPrerelease(), + [nameof(GitHubProjectName)] = "Pathoschild/SMAPI", + [nameof(WebApiBaseUrl)] = "https://api.smapi.io", + [nameof(VerboseLogging)] = false, + [nameof(LogNetworkTraffic)] = false, + [nameof(DumpMetadata)] = false + }; + + /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> + private static readonly HashSet<string> DefaultSuppressUpdateChecks = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase) + { + "SMAPI.ConsoleCommands", + "SMAPI.SaveBackup" + }; + + + /******** ** Accessors ********/ /// <summary>Whether to enable development features.</summary> @@ -15,15 +47,10 @@ namespace StardewModdingAPI.Framework.Models public bool CheckForUpdates { get; set; } /// <summary>Whether to add a section to the 'mod issues' list for mods which which directly use potentially sensitive .NET APIs like file or shell access.</summary> - public bool ParanoidWarnings { get; set; } = -#if DEBUG - true; -#else - false; -#endif + public bool ParanoidWarnings { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.ParanoidWarnings)]; /// <summary>Whether to show beta versions as valid updates.</summary> - public bool UseBetaChannel { get; set; } = Constants.ApiVersion.IsPrerelease(); + public bool UseBetaChannel { get; set; } = (bool)SConfig.DefaultValues[nameof(SConfig.UseBetaChannel)]; /// <summary>SMAPI's GitHub project name, used to perform update checks.</summary> public string GitHubProjectName { get; set; } @@ -34,13 +61,39 @@ namespace StardewModdingAPI.Framework.Models /// <summary>Whether SMAPI should log more information about the game context.</summary> public bool VerboseLogging { get; set; } + /// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary> + public bool LogNetworkTraffic { get; set; } + /// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary> public bool DumpMetadata { get; set; } - /// <summary>The console color scheme to use.</summary> - public MonitorColorScheme ColorScheme { get; set; } + /// <summary>The colors to use for text written to the SMAPI console.</summary> + public ColorSchemeConfig ConsoleColors { get; set; } /// <summary>The mod IDs SMAPI should ignore when performing update checks or validating update keys.</summary> public string[] SuppressUpdateChecks { get; set; } + + + /******** + ** Public methods + ********/ + /// <summary>Get the settings which have been customised by the player.</summary> + public IDictionary<string, object> GetCustomSettings() + { + IDictionary<string, object> custom = new Dictionary<string, object>(); + + foreach (var pair in SConfig.DefaultValues) + { + object value = typeof(SConfig).GetProperty(pair.Key)?.GetValue(this); + if (!pair.Value.Equals(value)) + custom[pair.Key] = value; + } + + HashSet<string> curSuppressUpdateChecks = new HashSet<string>(this.SuppressUpdateChecks ?? new string[0], StringComparer.InvariantCultureIgnoreCase); + if (SConfig.DefaultSuppressUpdateChecks.Count != curSuppressUpdateChecks.Count || SConfig.DefaultSuppressUpdateChecks.Any(p => !curSuppressUpdateChecks.Contains(p))) + custom[nameof(this.SuppressUpdateChecks)] = "[" + string.Join(", ", this.SuppressUpdateChecks ?? new string[0]) + "]"; + + return custom; + } } } diff --git a/src/SMAPI/Framework/Monitor.cs b/src/SMAPI/Framework/Monitor.cs index 617bfd85..06cf1b46 100644 --- a/src/SMAPI/Framework/Monitor.cs +++ b/src/SMAPI/Framework/Monitor.cs @@ -1,6 +1,5 @@ using System; using System.Linq; -using System.Threading; using StardewModdingAPI.Framework.Logging; using StardewModdingAPI.Internal.ConsoleWriting; @@ -27,16 +26,10 @@ namespace StardewModdingAPI.Framework /// <summary>The maximum length of the <see cref="LogLevel"/> values.</summary> private static readonly int MaxLevelLength = (from level in Enum.GetValues(typeof(LogLevel)).Cast<LogLevel>() select level.ToString().Length).Max(); - /// <summary>Propagates notification that SMAPI should exit.</summary> - private readonly CancellationTokenSource ExitTokenSource; - /********* ** Accessors *********/ - /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> - public bool IsExiting => this.ExitTokenSource.IsCancellationRequested; - /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> public bool IsVerbose { get; } @@ -57,21 +50,19 @@ namespace StardewModdingAPI.Framework /// <param name="source">The name of the module which logs messages using this instance.</param> /// <param name="consoleInterceptor">Intercepts access to the console output.</param> /// <param name="logFile">The log file to which to write messages.</param> - /// <param name="exitTokenSource">Propagates notification that SMAPI should exit.</param> - /// <param name="colorScheme">The console color scheme to use.</param> + /// <param name="colorConfig">The colors to use for text written to the SMAPI console.</param> /// <param name="isVerbose">Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</param> - public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, CancellationTokenSource exitTokenSource, MonitorColorScheme colorScheme, bool isVerbose) + public Monitor(string source, ConsoleInterceptionManager consoleInterceptor, LogFileManager logFile, ColorSchemeConfig colorConfig, bool isVerbose) { // validate if (string.IsNullOrWhiteSpace(source)) throw new ArgumentException("The log source cannot be empty."); - // initialise + // initialize this.Source = source; this.LogFile = logFile ?? throw new ArgumentNullException(nameof(logFile), "The log file manager cannot be null."); - this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorScheme); + this.ConsoleWriter = new ColorfulConsoleWriter(Constants.Platform, colorConfig); this.ConsoleInterceptor = consoleInterceptor; - this.ExitTokenSource = exitTokenSource; this.IsVerbose = isVerbose; } @@ -91,14 +82,6 @@ namespace StardewModdingAPI.Framework this.Log(message, LogLevel.Trace); } - /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> - /// <param name="reason">The reason for the shutdown.</param> - public void ExitGameImmediately(string reason) - { - this.LogFatal($"{this.Source} requested an immediate game shutdown: {reason}"); - this.ExitTokenSource.Cancel(); - } - /// <summary>Write a newline to the console and log file.</summary> internal void Newline() { @@ -107,6 +90,13 @@ namespace StardewModdingAPI.Framework this.LogFile.WriteLine(""); } + /// <summary>Log a fatal error message.</summary> + /// <param name="message">The message to log.</param> + internal void LogFatal(string message) + { + this.LogImpl(this.Source, message, ConsoleLogLevel.Critical); + } + /// <summary>Log console input from the user.</summary> /// <param name="input">The user input to log.</param> internal void LogUserInput(string input) @@ -120,13 +110,6 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Log a fatal error message.</summary> - /// <param name="message">The message to log.</param> - private void LogFatal(string message) - { - this.LogImpl(this.Source, message, ConsoleLogLevel.Critical); - } - /// <summary>Write a message line to the log.</summary> /// <param name="source">The name of the mod logging the message.</param> /// <param name="message">The message to log.</param> diff --git a/src/SMAPI/Framework/Networking/MessageType.cs b/src/SMAPI/Framework/Networking/MessageType.cs index bd9acfa9..4e1388ca 100644 --- a/src/SMAPI/Framework/Networking/MessageType.cs +++ b/src/SMAPI/Framework/Networking/MessageType.cs @@ -2,7 +2,7 @@ using StardewValley; namespace StardewModdingAPI.Framework.Networking { - /// <summary>Network message types recognised by SMAPI and Stardew Valley.</summary> + /// <summary>Network message types recognized by SMAPI and Stardew Valley.</summary> internal enum MessageType : byte { /********* diff --git a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs index bb67f70e..7dbfa767 100644 --- a/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs +++ b/src/SMAPI/Framework/Networking/SGalaxyNetServer.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.Networking { NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); GalaxyID capturedPeer = new GalaxyID(peerID); - this.gameServer.checkFarmhandRequest(Convert.ToString(peerID), farmer, msg => this.sendMessage(capturedPeer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = capturedPeer.ToUint64()); + 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 1bce47fe..f2c61917 100644 --- a/src/SMAPI/Framework/Networking/SLidgrenServer.cs +++ b/src/SMAPI/Framework/Networking/SLidgrenServer.cs @@ -33,6 +33,10 @@ namespace StardewModdingAPI.Framework.Networking this.OnProcessingMessage = onProcessingMessage; } + + /********* + ** Protected methods + *********/ /// <summary>Parse a data message from a client.</summary> /// <param name="rawMessage">The raw network message to parse.</param> [SuppressMessage("ReSharper", "AccessToDisposedClosure", Justification = "The callback is invoked synchronously.")] @@ -55,7 +59,7 @@ namespace StardewModdingAPI.Framework.Networking else if (message.MessageType == StardewValley.Multiplayer.playerIntroduction) { NetFarmerRoot farmer = this.Multiplayer.readFarmer(message.Reader); - this.gameServer.checkFarmhandRequest("", farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); + this.gameServer.checkFarmhandRequest("", this.getConnectionId(rawMessage.SenderConnection), farmer, msg => this.sendMessage(peer, msg), () => this.peers[farmer.Value.UniqueMultiplayerID] = peer); } }); } diff --git a/src/SMAPI/Framework/Reflection/Reflector.cs b/src/SMAPI/Framework/Reflection/Reflector.cs index ed1a4381..d4904878 100644 --- a/src/SMAPI/Framework/Reflection/Reflector.cs +++ b/src/SMAPI/Framework/Reflection/Reflector.cs @@ -6,7 +6,7 @@ using System.Runtime.Caching; namespace StardewModdingAPI.Framework.Reflection { /// <summary>Provides helper methods for accessing inaccessible code.</summary> - /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimise performance without unnecessary memory usage).</remarks> + /// <remarks>This implementation searches up the type hierarchy, and caches the reflected fields and methods with a sliding expiry (to optimize performance without unnecessary memory usage).</remarks> internal class Reflector { /********* diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index 5dd52992..afb82679 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -24,13 +24,12 @@ using StardewModdingAPI.Framework.ModHelpers; using StardewModdingAPI.Framework.ModLoading; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.Serialisation; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Framework.Serialization; using StardewModdingAPI.Patches; using StardewModdingAPI.Toolkit; using StardewModdingAPI.Toolkit.Framework.Clients.WebApi; using StardewModdingAPI.Toolkit.Framework.ModData; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; using Object = StardewValley.Object; @@ -38,7 +37,7 @@ using ThreadState = System.Threading.ThreadState; namespace StardewModdingAPI.Framework { - /// <summary>The core class which initialises and manages SMAPI.</summary> + /// <summary>The core class which initializes and manages SMAPI.</summary> internal class SCore : IDisposable { /********* @@ -56,12 +55,15 @@ namespace StardewModdingAPI.Framework /// <summary>The core logger and monitor on behalf of the game.</summary> private readonly Monitor MonitorForGame; - /// <summary>Tracks whether the game should exit immediately and any pending initialisation should be cancelled.</summary> - private readonly CancellationTokenSource CancellationTokenSource = new CancellationTokenSource(); + /// <summary>Tracks whether the game should exit immediately and any pending initialization should be cancelled.</summary> + private readonly CancellationTokenSource CancellationToken = new CancellationTokenSource(); /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection = new Reflector(); + /// <summary>Encapsulates access to SMAPI core translations.</summary> + private readonly Translator Translator = new Translator(); + /// <summary>The SMAPI configuration settings.</summary> private readonly SConfig Settings; @@ -72,7 +74,7 @@ namespace StardewModdingAPI.Framework private ContentCoordinator ContentCore => this.GameInstance.ContentCore; /// <summary>Tracks the installed mods.</summary> - /// <remarks>This is initialised after the game starts.</remarks> + /// <remarks>This is initialized after the game starts.</remarks> private readonly ModRegistry ModRegistry = new ModRegistry(); /// <summary>Manages SMAPI events for mods.</summary> @@ -84,15 +86,14 @@ namespace StardewModdingAPI.Framework /// <summary>Whether the program has been disposed.</summary> private bool IsDisposed; - /// <summary>Regex patterns which match console messages to suppress from the console and log.</summary> + /// <summary>Regex patterns which match console non-error messages to suppress from the console and log.</summary> private readonly Regex[] SuppressConsolePatterns = { new Regex(@"^TextBox\.Selected is now '(?:True|False)'\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^(?:FRUIT )?TREE: IsClient:(?:True|False) randomOutput: \d+$", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^loadPreferences\(\); begin", RegexOptions.Compiled | RegexOptions.CultureInvariant), new Regex(@"^savePreferences\(\); async=", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant), - new Regex(@"^static SerializableDictionary<.+>\(\) called\.$", RegexOptions.Compiled | RegexOptions.CultureInvariant), + new Regex(@"^DebugOutput:\s+(?:added CLOUD|added cricket|dismount tile|Ping|playerPos)", RegexOptions.Compiled | RegexOptions.CultureInvariant) }; /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> @@ -120,7 +121,7 @@ namespace StardewModdingAPI.Framework ** Accessors *********/ /// <summary>Manages deprecation warnings.</summary> - /// <remarks>This is initialised after the game starts. This is accessed directly because it's not part of the normal class model.</remarks> + /// <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; } @@ -144,7 +145,7 @@ namespace StardewModdingAPI.Framework // init basics this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath)); this.LogFile = new LogFileManager(logPath); - this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = writeToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, @@ -165,6 +166,13 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("(Using custom --mods-path argument.)", LogLevel.Trace); this.Monitor.Log($"Log started at {DateTime.UtcNow:s} UTC", LogLevel.Trace); + // log custom settings + { + IDictionary<string, object> customSettings = this.Settings.GetCustomSettings(); + if (customSettings.Any()) + this.Monitor.Log($"Loaded with custom settings: {string.Join(", ", customSettings.OrderBy(p => p.Key).Select(p => $"{p.Key}: {p.Value}"))}", LogLevel.Trace); + } + // validate platform #if SMAPI_FOR_WINDOWS if (Constants.Platform != Platform.Windows) @@ -187,27 +195,9 @@ namespace StardewModdingAPI.Framework [HandleProcessCorruptedStateExceptions, SecurityCritical] // let try..catch handle corrupted state exceptions public void RunInteractively() { - // initialise SMAPI + // initialize SMAPI try { -#if !SMAPI_3_0_STRICT - // hook up events - ContentEvents.Init(this.EventManager); - ControlEvents.Init(this.EventManager); - GameEvents.Init(this.EventManager); - GraphicsEvents.Init(this.EventManager); - InputEvents.Init(this.EventManager); - LocationEvents.Init(this.EventManager); - MenuEvents.Init(this.EventManager); - MineEvents.Init(this.EventManager); - MultiplayerEvents.Init(this.EventManager); - PlayerEvents.Init(this.EventManager); - SaveEvents.Init(this.EventManager); - SpecialisedEvents.Init(this.EventManager); - TimeEvents.Init(this.EventManager); -#endif - - // init JSON parser JsonConverter[] converters = { new ColorConverter(), new PointConverter(), @@ -223,12 +213,29 @@ namespace StardewModdingAPI.Framework #endif AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error); - // add more leniant assembly resolvers + // add more lenient assembly resolvers AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name); + // hook locale event + LocalizedContentManager.OnLanguageChange += locale => this.OnLocaleChanged(); + // override game - SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper); - this.GameInstance = new SGame(this.Monitor, this.MonitorForGame, this.Reflection, this.EventManager, this.Toolkit.JsonHelper, this.ModRegistry, SCore.DeprecationManager, this.OnLocaleChanged, this.InitialiseAfterGameStart, this.Dispose); + SGame.ConstructorHack = new SGameConstructorHack(this.Monitor, this.Reflection, this.Toolkit.JsonHelper, this.InitializeBeforeFirstAssetLoaded); + this.GameInstance = new SGame( + monitor: this.Monitor, + monitorForGame: this.MonitorForGame, + reflection: this.Reflection, + translator: this.Translator, + eventManager: this.EventManager, + jsonHelper: this.Toolkit.JsonHelper, + modRegistry: this.ModRegistry, + deprecationManager: SCore.DeprecationManager, + onGameInitialized: this.InitializeAfterGameStart, + onGameExiting: this.Dispose, + cancellationToken: this.CancellationToken, + logNetworkTraffic: this.Settings.LogNetworkTraffic + ); + this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language); StardewValley.Program.gamePtr = this.GameInstance; // apply game patches @@ -236,13 +243,14 @@ namespace StardewModdingAPI.Framework new EventErrorPatch(this.MonitorForGame), new DialogueErrorPatch(this.MonitorForGame, this.Reflection), new ObjectErrorPatch(), - new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged) + new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged), + new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved) ); // add exit handler new Thread(() => { - this.CancellationTokenSource.Token.WaitHandle.WaitOne(); + this.CancellationToken.Token.WaitHandle.WaitOne(); if (this.IsGameRunning) { try @@ -262,14 +270,10 @@ namespace StardewModdingAPI.Framework // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif } catch (Exception ex) { - this.Monitor.Log($"SMAPI failed to initialise: {ex.GetLogSummary()}", LogLevel.Error); + this.Monitor.Log($"SMAPI failed to initialize: {ex.GetLogSummary()}", LogLevel.Error); this.PressAnyKeyToExit(); return; } @@ -302,6 +306,19 @@ namespace StardewModdingAPI.Framework File.Delete(Constants.FatalCrashMarker); } + // add headers + if (this.Settings.DeveloperMode) + this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); + if (!this.Settings.CheckForUpdates) + this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); + if (!this.Monitor.WriteToConsole) + this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); + this.Monitor.VerboseLog("Verbose logging enabled."); + + // update window titles + this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion}"; + Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion}"; + // start game this.Monitor.Log("Starting game...", LogLevel.Debug); try @@ -359,7 +376,7 @@ namespace StardewModdingAPI.Framework this.IsGameRunning = false; this.ConsoleManager?.Dispose(); this.ContentCore?.Dispose(); - this.CancellationTokenSource?.Dispose(); + this.CancellationToken?.Dispose(); this.GameInstance?.Dispose(); this.LogFile?.Dispose(); @@ -371,24 +388,14 @@ namespace StardewModdingAPI.Framework /********* ** Private methods *********/ - /// <summary>Initialise SMAPI and mods after the game starts.</summary> - private void InitialiseAfterGameStart() + /// <summary>Initialize mods before the first game asset is loaded. At this point the core content managers are loaded (so mods can load their own assets), but the game is mostly uninitialized.</summary> + private void InitializeBeforeFirstAssetLoaded() { - // add headers -#if SMAPI_3_0_STRICT - this.Monitor.Log($"You're running SMAPI 3.0 strict mode, so most mods won't work correctly. If that wasn't intended, install the normal version of SMAPI from https://smapi.io instead.", LogLevel.Warn); -#endif - if (this.Settings.DeveloperMode) - this.Monitor.Log($"You have SMAPI for developers, so the console will be much more verbose. You can disable developer mode by installing the non-developer version of SMAPI, or by editing {Constants.ApiConfigPath}.", LogLevel.Info); - if (!this.Settings.CheckForUpdates) - this.Monitor.Log($"You configured SMAPI to not check for updates. Running an old version of SMAPI is not recommended. You can enable update checks by reinstalling SMAPI or editing {Constants.ApiConfigPath}.", LogLevel.Warn); - if (!this.Monitor.WriteToConsole) - this.Monitor.Log("Writing to the terminal is disabled because the --no-terminal argument was received. This usually means launching the terminal failed.", LogLevel.Warn); - this.Monitor.VerboseLog("Verbose logging enabled."); - - // validate XNB integrity - if (!this.ValidateContentIntegrity()) - this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); + if (this.CancellationToken.IsCancellationRequested) + { + this.Monitor.Log("SMAPI shutting down: aborting initialization.", LogLevel.Warn); + return; + } // load mod data ModToolkit toolkit = new ModToolkit(); @@ -399,12 +406,19 @@ namespace StardewModdingAPI.Framework this.Monitor.Log("Loading mod metadata...", LogLevel.Trace); ModResolver resolver = new ModResolver(); + // log loose files + { + string[] looseFiles = new DirectoryInfo(this.ModsPath).GetFiles().Select(p => p.Name).ToArray(); + if (looseFiles.Any()) + this.Monitor.Log($" Ignored loose files: {string.Join(", ", looseFiles.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}", LogLevel.Trace); + } + // load manifests IModMetadata[] mods = resolver.ReadManifests(toolkit, this.ModsPath, modDatabase).ToArray(); // filter out ignored mods foreach (IModMetadata mod in mods.Where(p => p.IsIgnored)) - this.Monitor.Log($" Skipped {mod.RelativeDirectoryPath} (folder name starts with a dot).", LogLevel.Trace); + this.Monitor.Log($" Skipped {mod.GetRelativePathWithRoot()} (folder name starts with a dot).", LogLevel.Trace); mods = mods.Where(p => !p.IsIgnored).ToArray(); // load mods @@ -429,21 +443,19 @@ namespace StardewModdingAPI.Framework // check for updates this.CheckForUpdatesAsync(mods); } - if (this.Monitor.IsExiting) - { - this.Monitor.Log("SMAPI shutting down: aborting initialisation.", LogLevel.Warn); - return; - } // update window titles int modsLoaded = this.ModRegistry.GetAll().Count(); this.GameInstance.Window.Title = $"Stardew Valley {Constants.GameVersion} - running SMAPI {Constants.ApiVersion} with {modsLoaded} mods"; Console.Title = $"SMAPI {Constants.ApiVersion} - running Stardew Valley {Constants.GameVersion} with {modsLoaded} mods"; -#if SMAPI_3_0_STRICT - this.GameInstance.Window.Title += " [SMAPI 3.0 strict mode]"; - Console.Title += " [SMAPI 3.0 strict mode]"; -#endif + } + /// <summary>Initialize SMAPI and mods after the game starts.</summary> + private void InitializeAfterGameStart() + { + // validate XNB integrity + if (!this.ValidateContentIntegrity()) + this.Monitor.Log("SMAPI found problems in your game's content files which are likely to cause errors or crashes. Consider uninstalling XNB mods or reinstalling the game.", LogLevel.Error); // start SMAPI console new Thread(this.RunConsoleLoop).Start(); @@ -452,13 +464,18 @@ namespace StardewModdingAPI.Framework /// <summary>Handle the game changing locale.</summary> private void OnLocaleChanged() { + this.ContentCore.OnLocaleChanged(); + // get locale string locale = this.ContentCore.GetLocale(); LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + // update core translations + this.Translator.SetLocale(locale, languageCode); + // update mod translation helpers - foreach (IModMetadata mod in this.ModRegistry.GetAll(contentPacks: false)) - (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + foreach (IModMetadata mod in this.ModRegistry.GetAll()) + mod.Translations.SetLocale(locale, languageCode); } /// <summary>Run a loop handling console input.</summary> @@ -488,7 +505,7 @@ namespace StardewModdingAPI.Framework inputThread.Start(); // keep console thread alive while the game is running - while (this.IsGameRunning && !this.Monitor.IsExiting) + while (this.IsGameRunning && !this.CancellationToken.IsCancellationRequested) Thread.Sleep(1000 / 10); if (inputThread.ThreadState == ThreadState.Running) inputThread.Abort(); @@ -570,27 +587,19 @@ namespace StardewModdingAPI.Framework ISemanticVersion updateFound = null; try { - ModEntryModel response = client.GetModInfo(new[] { new ModSearchEntryModel("Pathoschild.SMAPI", new[] { $"GitHub:{this.Settings.GitHubProjectName}" }) }).Single().Value; - ISemanticVersion latestStable = response.Main?.Version; - ISemanticVersion latestBeta = response.Optional?.Version; + // 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; + if (response.SuggestedUpdate != null) + this.Monitor.Log($"You can update SMAPI to {response.SuggestedUpdate.Version}: {Constants.HomePageUrl}", LogLevel.Alert); + else + this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); - if (latestStable == null && response.Errors.Any()) + // show errors + if (response.Errors.Any()) { this.Monitor.Log("Couldn't check for a new version of SMAPI. This won't affect your game, but you may not be notified of new versions if this keeps happening.", LogLevel.Warn); this.Monitor.Log($"Error: {string.Join("\n", response.Errors)}", LogLevel.Trace); } - else if (this.IsValidUpdate(Constants.ApiVersion, latestBeta, this.Settings.UseBetaChannel)) - { - updateFound = latestBeta; - this.Monitor.Log($"You can update SMAPI to {latestBeta}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else if (this.IsValidUpdate(Constants.ApiVersion, latestStable, this.Settings.UseBetaChannel)) - { - updateFound = latestStable; - this.Monitor.Log($"You can update SMAPI to {latestStable}: {Constants.HomePageUrl}", LogLevel.Alert); - } - else - this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } catch (Exception ex) { @@ -623,12 +632,12 @@ namespace StardewModdingAPI.Framework .GetUpdateKeys(validOnly: true) .Select(p => p.ToString()) .ToArray(); - searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, updateKeys.ToArray())); + searchMods.Add(new ModSearchEntryModel(mod.Manifest.UniqueID, mod.Manifest.Version, updateKeys.ToArray(), isBroken: mod.Status == ModMetadataStatus.Failed)); } // fetch results this.Monitor.Log($" Checking for updates to {searchMods.Count} mods...", LogLevel.Trace); - IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray()); + IDictionary<string, ModEntryModel> results = client.GetModInfo(searchMods.ToArray(), apiVersion: Constants.ApiVersion, gameVersion: Constants.GameVersion, platform: Constants.Platform); // extract update alerts & errors var updates = new List<Tuple<IModMetadata, ISemanticVersion, string>>(); @@ -649,20 +658,9 @@ namespace StardewModdingAPI.Framework ); } - // parse versions - bool useBetaInfo = result.HasBetaInfo && Constants.ApiVersion.IsPrerelease(); - ISemanticVersion localVersion = mod.DataRecord?.GetLocalVersionForUpdateChecks(mod.Manifest.Version) ?? mod.Manifest.Version; - ISemanticVersion latestVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Main?.Version) ?? result.Main?.Version; - ISemanticVersion optionalVersion = mod.DataRecord?.GetRemoteVersionForUpdateChecks(result.Optional?.Version) ?? result.Optional?.Version; - ISemanticVersion unofficialVersion = useBetaInfo ? result.UnofficialForBeta?.Version : result.Unofficial?.Version; - - // show update alerts - if (this.IsValidUpdate(localVersion, latestVersion, useBetaChannel: true)) - updates.Add(Tuple.Create(mod, latestVersion, result.Main?.Url)); - else if (this.IsValidUpdate(localVersion, optionalVersion, useBetaChannel: localVersion.IsPrerelease())) - updates.Add(Tuple.Create(mod, optionalVersion, result.Optional?.Url)); - else if (this.IsValidUpdate(localVersion, unofficialVersion, useBetaChannel: mod.Status == ModMetadataStatus.Failed)) - updates.Add(Tuple.Create(mod, unofficialVersion, useBetaInfo ? result.UnofficialForBeta?.Url : result.Unofficial?.Url)); + // handle update + if (result.SuggestedUpdate != null) + updates.Add(Tuple.Create(mod, result.SuggestedUpdate.Version, result.SuggestedUpdate.Url)); } // show update errors @@ -697,18 +695,6 @@ namespace StardewModdingAPI.Framework }).Start(); } - /// <summary>Get whether a given version should be offered to the user as an update.</summary> - /// <param name="currentVersion">The current semantic version.</param> - /// <param name="newVersion">The target semantic version.</param> - /// <param name="useBetaChannel">Whether the user enabled the beta channel and should be offered pre-release updates.</param> - private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion, bool useBetaChannel) - { - return - newVersion != null - && newVersion.IsNewerThan(currentVersion) - && (useBetaChannel || !newVersion.IsPrerelease()); - } - /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> private void VerifyPath(string path) @@ -720,7 +706,7 @@ namespace StardewModdingAPI.Framework } catch (Exception ex) { - // note: this happens before this.Monitor is initialised + // note: this happens before this.Monitor is initialized Console.WriteLine($"Couldn't create a path: {path}\n\n{ex.GetLogSummary()}"); } } @@ -755,8 +741,9 @@ namespace StardewModdingAPI.Framework LogSkip(contentPack, errorPhrase, errorDetails); } } - IModMetadata[] loadedContentPacks = this.ModRegistry.GetAll(assemblyMods: false).ToArray(); - IModMetadata[] loadedMods = this.ModRegistry.GetAll(contentPacks: false).ToArray(); + IModMetadata[] loaded = this.ModRegistry.GetAll().ToArray(); + IModMetadata[] loadedContentPacks = loaded.Where(p => p.IsContentPack).ToArray(); + IModMetadata[] loadedMods = loaded.Where(p => !p.IsContentPack).ToArray(); // unlock content packs this.ModRegistry.AreAllModsLoaded = true; @@ -796,12 +783,12 @@ namespace StardewModdingAPI.Framework } // log mod warnings - this.LogModWarnings(this.ModRegistry.GetAll().ToArray(), skippedMods); + this.LogModWarnings(loaded, skippedMods); - // initialise translations - this.ReloadTranslations(loadedMods); + // initialize translations + this.ReloadTranslations(loaded); - // initialise loaded non-content-pack mods + // initialize loaded non-content-pack mods foreach (IModMetadata metadata in loadedMods) { // add interceptors @@ -850,7 +837,7 @@ namespace StardewModdingAPI.Framework } // invalidate cache entries when needed - // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialise.) + // (These listeners are registered after Entry to avoid repeatedly reloading assets as mods initialize.) foreach (IModMetadata metadata in loadedMods) { if (metadata.Mod.Helper.Content is ContentHelper helper) @@ -884,7 +871,7 @@ namespace StardewModdingAPI.Framework } // unlock mod integrations - this.ModRegistry.AreAllModsInitialised = true; + this.ModRegistry.AreAllModsInitialized = true; } /// <summary>Load a given mod.</summary> @@ -905,13 +892,13 @@ namespace StardewModdingAPI.Framework // log entry { - string relativePath = PathUtilities.GetRelativePath(this.ModsPath, mod.DirectoryPath); + string relativePath = mod.GetRelativePathWithRoot(); if (mod.IsContentPack) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}) [content pack]...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}) [content pack]...", LogLevel.Trace); else if (mod.Manifest?.EntryDll != null) - this.Monitor.Log($" {mod.DisplayName} ({relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid + this.Monitor.Log($" {mod.DisplayName} (from {relativePath}{Path.DirectorySeparatorChar}{mod.Manifest.EntryDll})...", LogLevel.Trace); // don't use Path.Combine here, since EntryDLL might not be valid else - this.Monitor.Log($" {mod.DisplayName} ({relativePath})...", LogLevel.Trace); + this.Monitor.Log($" {mod.DisplayName} (from {relativePath})...", LogLevel.Trace); } // add warning for missing update key @@ -926,16 +913,8 @@ namespace StardewModdingAPI.Framework return false; } -#if !SMAPI_3_0_STRICT - // add deprecation warning for old version format - { - if (mod.Manifest?.Version is Toolkit.SemanticVersion version && version.IsLegacyFormat) - SCore.DeprecationManager.Warn(mod.DisplayName, "non-string manifest version", "2.8", DeprecationLevel.PendingRemoval); - } -#endif - // validate dependencies - // Although dependences are validated before mods are loaded, a dependency may have failed to load. + // Although dependencies are validated before mods are loaded, a dependency may have failed to load. if (mod.Manifest.Dependencies?.Any() == true) { foreach (IManifestDependency dependency in mod.Manifest.Dependencies.Where(p => p.IsRequired)) @@ -957,8 +936,9 @@ namespace StardewModdingAPI.Framework IManifest manifest = mod.Manifest; IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); IContentHelper contentHelper = new ContentHelper(this.ContentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); - IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, jsonHelper); - mod.SetMod(contentPack, monitor); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + IContentPack contentPack = new ContentPack(mod.DirectoryPath, manifest, contentHelper, translationHelper, jsonHelper); + mod.SetMod(contentPack, monitor, translationHelper); this.ModRegistry.Add(mod); errorReasonPhrase = null; @@ -998,7 +978,7 @@ namespace StardewModdingAPI.Framework return false; } - // initialise mod + // initialize mod try { // get mod instance @@ -1020,8 +1000,17 @@ namespace StardewModdingAPI.Framework // init mod helpers IMonitor monitor = this.GetSecondaryMonitor(mod.DisplayName); + TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { + IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + } + IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); @@ -1030,16 +1019,8 @@ namespace StardewModdingAPI.Framework 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.GameInstance.Multiplayer); - ITranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, manifest.Name, contentCore.GetLocale(), contentCore.Language); - - IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - return new ContentPack(packDirPath, packManifest, packContentHelper, this.Toolkit.JsonHelper); - } - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.Toolkit.JsonHelper, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); + modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } // init mod @@ -1048,13 +1029,13 @@ namespace StardewModdingAPI.Framework modEntry.Monitor = monitor; // track mod - mod.SetMod(modEntry); + mod.SetMod(modEntry, translationHelper); this.ModRegistry.Add(mod); return true; } catch (Exception ex) { - errorReasonPhrase = $"initialisation failed:\n{ex.GetLogSummary()}"; + errorReasonPhrase = $"initialization failed:\n{ex.GetLogSummary()}"; return false; } } @@ -1063,7 +1044,7 @@ namespace StardewModdingAPI.Framework /// <summary>Write a summary of mod warnings to the console and log.</summary> /// <param name="mods">The loaded mods.</param> /// <param name="skippedMods">The mods which were skipped, along with the friendly and developer reasons.</param> - private void LogModWarnings(IModMetadata[] mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) + private void LogModWarnings(IEnumerable<IModMetadata> mods, IDictionary<IModMetadata, Tuple<string, string>> skippedMods) { // get mods with warnings IModMetadata[] modsWithWarnings = mods.Where(p => p.Warnings != ModWarning.None).ToArray(); @@ -1129,8 +1110,8 @@ namespace StardewModdingAPI.Framework "These mods have broken code, but you configured SMAPI to load them anyway. This may cause bugs,", "errors, or crashes in-game." ); - LogWarningGroup(ModWarning.ChangesSaveSerialiser, LogLevel.Warn, "Changed save serialiser", - "These mods change the save serialiser. They may corrupt your save files, or make them unusable if", + LogWarningGroup(ModWarning.ChangesSaveSerializer, LogLevel.Warn, "Changed save serializer", + "These mods change the save serializer. They may corrupt your save files, or make them unusable if", "you uninstall these mods." ); if (this.Settings.ParanoidWarnings) @@ -1200,64 +1181,85 @@ namespace StardewModdingAPI.Framework /// <param name="mods">The mods for which to reload translations.</param> private void ReloadTranslations(IEnumerable<IModMetadata> mods) { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; + // core SMAPI translations + { + var translations = this.ReadTranslationFiles(Path.Combine(Constants.InternalFilesPath, "i18n"), out IList<string> errors); + if (errors.Any() || !translations.Any()) + { + this.Monitor.Log("SMAPI couldn't load some core translations. You may need to reinstall SMAPI.", LogLevel.Warn); + foreach (string error in errors) + this.Monitor.Log($" - {error}", LogLevel.Warn); + } + this.Translator.SetTranslations(translations); + } + + // mod translations foreach (IModMetadata metadata in mods) { - if (metadata.IsContentPack) - throw new InvalidOperationException("Can't reload translations for a content pack."); + var translations = this.ReadTranslationFiles(Path.Combine(metadata.DirectoryPath, "i18n"), out IList<string> errors); + if (errors.Any()) + { + metadata.LogAsMod("Mod couldn't load some translation files:", LogLevel.Warn); + foreach (string error in errors) + metadata.LogAsMod($" - {error}", LogLevel.Warn); + } + metadata.Translations.SetTranslations(translations); + } + } + + /// <summary>Read translations from a directory containing JSON translation files.</summary> + /// <param name="folderPath">The folder path to search.</param> + /// <param name="errors">The errors indicating why translation files couldn't be parsed, indexed by translation filename.</param> + private IDictionary<string, IDictionary<string, string>> ReadTranslationFiles(string folderPath, out IList<string> errors) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; - // read translation files - IDictionary<string, IDictionary<string, string>> translations = new Dictionary<string, IDictionary<string, string>>(); - DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); - if (translationsDir.Exists) + // read translation files + var translations = new Dictionary<string, IDictionary<string, string>>(); + errors = new List<string>(); + DirectoryInfo translationsDir = new DirectoryInfo(folderPath); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) { - foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try { - string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); - try - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); - } - catch (Exception ex) + if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary<string, string> data)) { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + errors.Add($"{file.Name} file couldn't be read"); // should never happen, since we're iterating files that exist + continue; } - } - } - // validate translations - foreach (string locale in translations.Keys.ToArray()) - { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) + translations[locale] = data; + } + catch (Exception ex) { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); + errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}"); continue; } + } + } - // handle duplicates - HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // handle duplicates + HashSet<string> keys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + HashSet<string> duplicateKeys = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } + duplicateKeys.Add(key); + translations[locale].Remove(key); } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); } - - // update translation - TranslationHelper translationHelper = (TranslationHelper)metadata.Mod.Helper.Translation; - translationHelper.SetTranslations(translations); + if (duplicateKeys.Any()) + errors.Add($"{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive."); } + + return translations; } /// <summary>The method called when the user submits a core SMAPI command in the console.</summary> @@ -1298,7 +1300,7 @@ namespace StardewModdingAPI.Framework break; default: - throw new NotSupportedException($"Unrecognise core SMAPI command '{name}'."); + throw new NotSupportedException($"Unrecognized core SMAPI command '{name}'."); } } @@ -1351,7 +1353,7 @@ namespace StardewModdingAPI.Framework /// <param name="name">The name of the module which will log messages with this instance.</param> private Monitor GetSecondaryMonitor(string name) { - return new Monitor(name, this.ConsoleManager, this.LogFile, this.CancellationTokenSource, this.Settings.ColorScheme, this.Settings.VerboseLogging) + return new Monitor(name, this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging) { WriteToConsole = this.Monitor.WriteToConsole, ShowTraceInConsole = this.Settings.DeveloperMode, diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index 704eb6bc..47261862 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -9,9 +9,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; -#if !SMAPI_3_0_STRICT -using Microsoft.Xna.Framework.Input; -#endif using Netcode; using StardewModdingAPI.Enums; using StardewModdingAPI.Events; @@ -19,19 +16,18 @@ using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Framework.StateTracking; +using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.Utilities; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; using StardewValley.BellsAndWhistles; -using StardewValley.Buildings; +using StardewValley.Events; using StardewValley.Locations; using StardewValley.Menus; -using StardewValley.TerrainFeatures; using StardewValley.Tools; using xTile.Dimensions; using xTile.Layers; -using SObject = StardewValley.Object; +using xTile.Tiles; namespace StardewModdingAPI.Framework { @@ -45,7 +41,7 @@ namespace StardewModdingAPI.Framework ** SMAPI state ****/ /// <summary>Encapsulates monitoring and logging for SMAPI.</summary> - private readonly IMonitor Monitor; + private readonly Monitor Monitor; /// <summary>Encapsulates monitoring and logging on the game's behalf.</summary> private readonly IMonitor MonitorForGame; @@ -66,20 +62,23 @@ namespace StardewModdingAPI.Framework private readonly Countdown UpdateCrashTimer = new Countdown(60); // 60 ticks = roughly one second /// <summary>The number of ticks until SMAPI should notify mods that the game has loaded.</summary> - /// <remarks>Skipping a few frames ensures the game finishes initialising the world before mods try to change it.</remarks> + /// <remarks>Skipping a few frames ensures the game finishes initializing the world before mods try to change it.</remarks> private readonly Countdown AfterLoadTimer = new Countdown(5); + /// <summary>Whether custom content was removed from the save data to avoid a crash.</summary> + private bool IsSaveContentRemoved; + /// <summary>Whether the game is saving and SMAPI has already raised <see cref="IGameLoopEvents.Saving"/>.</summary> private bool IsBetweenSaveEvents; /// <summary>Whether the game is creating the save file and SMAPI has already raised <see cref="IGameLoopEvents.SaveCreating"/>.</summary> private bool IsBetweenCreateEvents; - /// <summary>A callback to invoke after the content language changes.</summary> - private readonly Action OnLocaleChanged; + /// <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 after the game finishes initialising.</summary> - private readonly Action OnGameInitialised; + /// <summary>A callback to invoke after the game finishes initializing.</summary> + private readonly Action OnGameInitialized; /// <summary>A callback to invoke when the game exits.</summary> private readonly Action OnGameExiting; @@ -87,14 +86,23 @@ namespace StardewModdingAPI.Framework /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; + /// <summary>Encapsulates access to SMAPI core translations.</summary> + private readonly Translator Translator; + + /// <summary>Propagates notification that SMAPI should exit.</summary> + private readonly CancellationTokenSource CancellationToken; + /**** ** Game state ****/ /// <summary>Monitors the entire game state for changes.</summary> private WatcherCore Watchers; - /// <summary>Whether post-game-startup initialisation has been performed.</summary> - private bool IsInitialised; + /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> + private WatcherSnapshot WatcherSnapshot = new WatcherSnapshot(); + + /// <summary>Whether post-game-startup initialization has been performed.</summary> + private bool IsInitialized; /// <summary>Whether the next content manager requested by the game will be for <see cref="Game1.content"/>.</summary> private bool NextContentManagerIsMain; @@ -103,7 +111,7 @@ namespace StardewModdingAPI.Framework /********* ** Accessors *********/ - /// <summary>Static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + /// <summary>Static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> internal static SGameConstructorHack ConstructorHack { get; set; } /// <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> @@ -133,20 +141,23 @@ namespace StardewModdingAPI.Framework /// <param name="monitor">Encapsulates monitoring and logging for SMAPI.</param> /// <param name="monitorForGame">Encapsulates monitoring and logging on the game's behalf.</param> /// <param name="reflection">Simplifies access to private game code.</param> + /// <param name="translator">Encapsulates access to arbitrary translations.</param> /// <param name="eventManager">Manages SMAPI events for mods.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> /// <param name="modRegistry">Tracks the installed mods.</param> /// <param name="deprecationManager">Manages deprecation warnings.</param> - /// <param name="onLocaleChanged">A callback to invoke after the content language changes.</param> - /// <param name="onGameInitialised">A callback to invoke after the game finishes initialising.</param> + /// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param> /// <param name="onGameExiting">A callback to invoke when the game exits.</param> - internal SGame(IMonitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onLocaleChanged, Action onGameInitialised, Action onGameExiting) + /// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param> + /// <param name="logNetworkTraffic">Whether to log network traffic.</param> + internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) { + this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset; SGame.ConstructorHack = null; // check expectations if (this.ContentCore == null) - throw new InvalidOperationException($"The game didn't initialise its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); + throw new InvalidOperationException($"The game didn't initialize its first content manager before SMAPI's {nameof(SGame)} constructor. This indicates an incompatible lifecycle change."); // init XNA Game1.graphics.GraphicsProfile = GraphicsProfile.HiDef; @@ -157,20 +168,21 @@ namespace StardewModdingAPI.Framework this.Events = eventManager; this.ModRegistry = modRegistry; this.Reflection = reflection; + this.Translator = translator; this.DeprecationManager = deprecationManager; - this.OnLocaleChanged = onLocaleChanged; - this.OnGameInitialised = onGameInitialised; + this.OnGameInitialized = onGameInitialized; this.OnGameExiting = onGameExiting; Game1.input = new SInputState(); - Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived); + Game1.multiplayer = new SMultiplayer(monitor, eventManager, jsonHelper, modRegistry, reflection, this.OnModMessageReceived, logNetworkTraffic); Game1.hooks = new SModHooks(this.OnNewDayAfterFade); + this.CancellationToken = cancellationToken; // init observables Game1.locations = new ObservableCollection<GameLocation>(); } - /// <summary>Initialise just before the game's first update tick.</summary> - private void InitialiseAfterGameStarted() + /// <summary>Initialize just before the game's first update tick.</summary> + private void InitializeAfterGameStarted() { // set initial state this.Input.TrueUpdate(); @@ -179,7 +191,7 @@ namespace StardewModdingAPI.Framework this.Watchers = new WatcherCore(this.Input); // raise callback - this.OnGameInitialised(); + this.OnGameInitialized(); } /// <summary>Perform cleanup logic when the game exits.</summary> @@ -188,7 +200,7 @@ namespace StardewModdingAPI.Framework /// <remarks>This overrides the logic in <see cref="Game1.exitEvent"/> to let SMAPI clean up before exit.</remarks> protected override void OnExiting(object sender, EventArgs args) { - Game1.multiplayer.Disconnect(); + Game1.multiplayer.Disconnect(StardewValley.Multiplayer.DisconnectType.ClosedGame); this.OnGameExiting?.Invoke(); } @@ -207,6 +219,12 @@ namespace StardewModdingAPI.Framework this.Events.ModMessageReceived.RaiseForMods(new ModMessageReceivedEventArgs(message), mod => mod != null && modIDs.Contains(mod.Manifest.UniqueID)); } + /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> + internal void OnSaveContentRemoved() + { + this.IsSaveContentRemoved = true; + } + /// <summary>A callback invoked when the game's low-level load stage changes.</summary> /// <param name="newStage">The new load stage.</param> internal void OnLoadStageChanged(LoadStage newStage) @@ -228,12 +246,7 @@ namespace StardewModdingAPI.Framework // raise events this.Events.LoadStageChanged.Raise(new LoadStageChangedEventArgs(oldStage, newStage)); if (newStage == LoadStage.None) - { this.Events.ReturnedToTitle.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - this.Events.Legacy_AfterReturnToTitle.Raise(); -#endif - } } /// <summary>Constructor a content manager to read XNB files.</summary> @@ -241,16 +254,16 @@ namespace StardewModdingAPI.Framework /// <param name="rootDirectory">The root directory to search for content.</param> protected override LocalizedContentManager CreateContentManager(IServiceProvider serviceProvider, string rootDirectory) { - // Game1._temporaryContent initialising from SGame constructor - // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialised at this point. + // Game1._temporaryContent initializing from SGame constructor + // NOTE: this method is called before the SGame constructor runs. Don't depend on anything being initialized at this point. if (this.ContentCore == null) { - this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper); + this.ContentCore = new ContentCoordinator(serviceProvider, rootDirectory, Thread.CurrentThread.CurrentUICulture, SGame.ConstructorHack.Monitor, SGame.ConstructorHack.Reflection, SGame.ConstructorHack.JsonHelper, this.OnLoadingFirstAsset ?? SGame.ConstructorHack?.OnLoadingFirstAsset); this.NextContentManagerIsMain = true; return this.ContentCore.CreateGameContentManager("Game1._temporaryContent"); } - // Game1.content initialising from LoadContent + // Game1.content initializing from LoadContent if (this.NextContentManagerIsMain) { this.NextContentManagerIsMain = false; @@ -272,17 +285,29 @@ namespace StardewModdingAPI.Framework this.DeprecationManager.PrintQueued(); /********* - ** Special cases + ** First-tick initialization *********/ - // Perform first-tick initialisation. - if (!this.IsInitialised) + if (!this.IsInitialized) { - this.IsInitialised = true; - this.InitialiseAfterGameStarted(); + this.IsInitialized = true; + this.InitializeAfterGameStarted(); } + /********* + ** Update input + *********/ + // This should *always* run, even when suppressing mod events, since the game uses + // this too. For example, doing this after mod event suppression would prevent the + // user from doing anything on the overnight shipping screen. + SInputState inputState = this.Input; + if (this.IsActive) + inputState.TrueUpdate(); + + /********* + ** Special cases + *********/ // Abort if SMAPI is exiting. - if (this.Monitor.IsExiting) + if (this.CancellationToken.IsCancellationRequested) { this.Monitor.Log("SMAPI shutting down: aborting update.", LogLevel.Trace); return; @@ -293,7 +318,7 @@ namespace StardewModdingAPI.Framework bool saveParsed = false; if (Game1.currentLoader != null) { - this.Monitor.Log("Game loader synchronising...", LogLevel.Trace); + this.Monitor.Log("Game loader synchronizing...", LogLevel.Trace); while (Game1.currentLoader?.MoveNext() == true) { // raise load stage changed @@ -324,7 +349,7 @@ namespace StardewModdingAPI.Framework } if (Game1._newDayTask?.Status == TaskStatus.Created) { - this.Monitor.Log("New day task synchronising...", LogLevel.Trace); + this.Monitor.Log("New day task synchronizing...", LogLevel.Trace); Game1._newDayTask.RunSynchronously(); this.Monitor.Log("New day task done.", LogLevel.Trace); } @@ -337,16 +362,45 @@ namespace StardewModdingAPI.Framework // Therefore we can just run Game1.Update here without raising any SMAPI events. There's // a small chance that the task will finish after we defer but before the game checks, // which means technically events should be raised, but the effects of missing one - // update tick are neglible and not worth the complications of bypassing Game1.Update. + // update tick are negligible and not worth the complications of bypassing Game1.Update. if (Game1._newDayTask != null || Game1.gameMode == Game1.loadingMode) { events.UnvalidatedUpdateTicking.RaiseEmpty(); SGame.TicksElapsed++; base.Update(gameTime); events.UnvalidatedUpdateTicked.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); -#endif + return; + } + + // Raise minimal events while saving. + // While the game is writing to the save file in the background, mods can unexpectedly + // fail since they don't have exclusive access to resources (e.g. collection changed + // during enumeration errors). To avoid problems, events are not invoked while a save + // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is + // opened (since the save hasn't started yet), but all other events should be suppressed. + if (Context.IsSaving) + { + // raise before-create + if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) + { + this.IsBetweenCreateEvents = true; + this.Monitor.Log("Context: before save creation.", LogLevel.Trace); + events.SaveCreating.RaiseEmpty(); + } + + // raise before-save + if (Context.IsWorldReady && !this.IsBetweenSaveEvents) + { + this.IsBetweenSaveEvents = true; + this.Monitor.Log("Context: before save.", LogLevel.Trace); + events.Saving.RaiseEmpty(); + } + + // suppress non-save events + events.UnvalidatedUpdateTicking.RaiseEmpty(); + SGame.TicksElapsed++; + base.Update(gameTime); + events.UnvalidatedUpdateTicked.RaiseEmpty(); return; } @@ -388,85 +442,6 @@ namespace StardewModdingAPI.Framework } /********* - ** Update input - *********/ - // This should *always* run, even when suppressing mod events, since the game uses - // this too. For example, doing this after mod event suppression would prevent the - // user from doing anything on the overnight shipping screen. -#if !SMAPI_3_0_STRICT - SInputState previousInputState = this.Input.Clone(); -#endif - SInputState inputState = this.Input; - if (this.IsActive) - inputState.TrueUpdate(); - - /********* - ** Save events + suppress events during save - *********/ - // While the game is writing to the save file in the background, mods can unexpectedly - // fail since they don't have exclusive access to resources (e.g. collection changed - // during enumeration errors). To avoid problems, events are not invoked while a save - // is in progress. It's safe to raise SaveEvents.BeforeSave as soon as the menu is - // opened (since the save hasn't started yet), but all other events should be suppressed. - if (Context.IsSaving) - { - // raise before-create - if (!Context.IsWorldReady && !this.IsBetweenCreateEvents) - { - this.IsBetweenCreateEvents = true; - this.Monitor.Log("Context: before save creation.", LogLevel.Trace); - events.SaveCreating.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_BeforeCreateSave.Raise(); -#endif - } - - // raise before-save - if (Context.IsWorldReady && !this.IsBetweenSaveEvents) - { - this.IsBetweenSaveEvents = true; - this.Monitor.Log("Context: before save.", LogLevel.Trace); - events.Saving.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_BeforeSave.Raise(); -#endif - } - - // suppress non-save events - events.UnvalidatedUpdateTicking.RaiseEmpty(); - SGame.TicksElapsed++; - base.Update(gameTime); - events.UnvalidatedUpdateTicked.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); -#endif - return; - } - if (this.IsBetweenCreateEvents) - { - // raise after-create - this.IsBetweenCreateEvents = false; - this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - this.OnLoadStageChanged(LoadStage.CreatedSaveFile); - events.SaveCreated.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterCreateSave.Raise(); -#endif - } - if (this.IsBetweenSaveEvents) - { - // raise after-save - this.IsBetweenSaveEvents = false; - this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); - events.Saved.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterSave.Raise(); - events.Legacy_AfterDayStarted.Raise(); -#endif - } - - /********* ** Update context *********/ bool wasWorldReady = Context.IsWorldReady; @@ -477,231 +452,170 @@ namespace StardewModdingAPI.Framework } else if (Context.IsSaveLoaded && this.AfterLoadTimer.Current > 0 && Game1.currentLocation != null) { - if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialised yet) + if (Game1.dayOfMonth != 0) // wait until new-game intro finishes (world not fully initialized yet) this.AfterLoadTimer.Decrement(); Context.IsWorldReady = this.AfterLoadTimer.Current == 0; } /********* ** Update watchers + ** (Watchers need to be updated, checked, and reset in one go so we can detect any changes mods make in event handlers.) *********/ this.Watchers.Update(); + this.WatcherSnapshot.Update(this.Watchers); + this.Watchers.Reset(); + WatcherSnapshot state = this.WatcherSnapshot; /********* - ** Locale changed events + ** Display in-game warnings *********/ - if (this.Watchers.LocaleWatcher.IsChanged) + // save content removed + if (this.IsSaveContentRemoved && Context.IsWorldReady) { - var was = this.Watchers.LocaleWatcher.PreviousValue; - var now = this.Watchers.LocaleWatcher.CurrentValue; - - this.Monitor.Log($"Context: locale set to {now}.", LogLevel.Trace); - - this.OnLocaleChanged(); -#if !SMAPI_3_0_STRICT - events.Legacy_LocaleChanged.Raise(new EventArgsValueChanged<string>(was.ToString(), now.ToString())); -#endif - - this.Watchers.LocaleWatcher.Reset(); + this.IsSaveContentRemoved = false; + Game1.addHUDMessage(new HUDMessage(this.Translator.Get("warn.invalid-content-removed"), HUDMessage.error_type)); } /********* - ** Load / return-to-title events + ** Pre-update events *********/ - if (wasWorldReady && !Context.IsWorldReady) - this.OnLoadStageChanged(LoadStage.None); - else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) { - // print context - string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; - if (Context.IsMultiplayer) + /********* + ** Save created/loaded events + *********/ + if (this.IsBetweenCreateEvents) { - int onlineCount = Game1.getOnlineFarmers().Count(); - context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; + // raise after-create + this.IsBetweenCreateEvents = false; + this.Monitor.Log($"Context: after save creation, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + this.OnLoadStageChanged(LoadStage.CreatedSaveFile); + events.SaveCreated.RaiseEmpty(); + } + if (this.IsBetweenSaveEvents) + { + // raise after-save + this.IsBetweenSaveEvents = false; + this.Monitor.Log($"Context: after save, starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}.", LogLevel.Trace); + events.Saved.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); } - else - context += " Single-player."; - this.Monitor.Log(context, LogLevel.Trace); - - // raise events - this.OnLoadStageChanged(LoadStage.Ready); - events.SaveLoaded.RaiseEmpty(); - events.DayStarted.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_AfterLoad.Raise(); - events.Legacy_AfterDayStarted.Raise(); -#endif - } - - /********* - ** Window events - *********/ - // Here we depend on the game's viewport instead of listening to the Window.Resize - // event because we need to notify mods after the game handles the resize, so the - // game's metadata (like Game1.viewport) are updated. That's a bit complicated - // since the game adds & removes its own handler on the fly. - if (this.Watchers.WindowSizeWatcher.IsChanged) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: window size changed to {this.Watchers.WindowSizeWatcher.CurrentValue}.", LogLevel.Trace); - Point oldSize = this.Watchers.WindowSizeWatcher.PreviousValue; - Point newSize = this.Watchers.WindowSizeWatcher.CurrentValue; + /********* + ** Locale changed events + *********/ + if (state.Locale.IsChanged) + this.Monitor.Log($"Context: locale set to {state.Locale.New}.", LogLevel.Trace); + + /********* + ** Load / return-to-title events + *********/ + if (wasWorldReady && !Context.IsWorldReady) + this.OnLoadStageChanged(LoadStage.None); + else if (Context.IsWorldReady && Context.LoadStage != LoadStage.Ready) + { + // print context + string context = $"Context: loaded save '{Constants.SaveFolderName}', starting {Game1.currentSeason} {Game1.dayOfMonth} Y{Game1.year}, locale set to {this.ContentCore.Language}."; + if (Context.IsMultiplayer) + { + int onlineCount = Game1.getOnlineFarmers().Count(); + context += $" {(Context.IsMainPlayer ? "Main player" : "Farmhand")} with {onlineCount} {(onlineCount == 1 ? "player" : "players")} online."; + } + else + context += " Single-player."; + this.Monitor.Log(context, LogLevel.Trace); - events.WindowResized.Raise(new WindowResizedEventArgs(oldSize, newSize)); -#if !SMAPI_3_0_STRICT - events.Legacy_Resize.Raise(); -#endif - this.Watchers.WindowSizeWatcher.Reset(); - } + // raise events + this.OnLoadStageChanged(LoadStage.Ready); + events.SaveLoaded.RaiseEmpty(); + events.DayStarted.RaiseEmpty(); + } - /********* - ** Input events (if window has focus) - *********/ - if (this.IsActive) - { - // raise events - bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); - if (!isChatInput) + /********* + ** Window events + *********/ + // Here we depend on the game's viewport instead of listening to the Window.Resize + // event because we need to notify mods after the game handles the resize, so the + // game's metadata (like Game1.viewport) are updated. That's a bit complicated + // since the game adds & removes its own handler on the fly. + if (state.WindowSize.IsChanged) { - ICursorPosition cursor = this.Input.CursorPosition; + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: window size changed to {state.WindowSize.New}.", LogLevel.Trace); - // raise cursor moved event - if (this.Watchers.CursorWatcher.IsChanged) + events.WindowResized.Raise(new WindowResizedEventArgs(state.WindowSize.Old, state.WindowSize.New)); + } + + /********* + ** Input events (if window has focus) + *********/ + if (this.IsActive) + { + // raise events + bool isChatInput = Game1.IsChatting || (Context.IsMultiplayer && Context.IsWorldReady && Game1.activeClickableMenu == null && Game1.currentMinigame == null && inputState.IsAnyDown(Game1.options.chatButton)); + if (!isChatInput) { - if (events.CursorMoved.HasListeners()) - { - ICursorPosition was = this.Watchers.CursorWatcher.PreviousValue; - ICursorPosition now = this.Watchers.CursorWatcher.CurrentValue; - this.Watchers.CursorWatcher.Reset(); + ICursorPosition cursor = this.Input.CursorPosition; - events.CursorMoved.Raise(new CursorMovedEventArgs(was, now)); - } - else - this.Watchers.CursorWatcher.Reset(); - } + // raise cursor moved event + if (state.Cursor.IsChanged) + events.CursorMoved.Raise(new CursorMovedEventArgs(state.Cursor.Old, state.Cursor.New)); - // raise mouse wheel scrolled - if (this.Watchers.MouseWheelScrollWatcher.IsChanged) - { - if (events.MouseWheelScrolled.HasListeners() || this.Monitor.IsVerbose) + // raise mouse wheel scrolled + if (state.MouseWheelScroll.IsChanged) { - int was = this.Watchers.MouseWheelScrollWatcher.PreviousValue; - int now = this.Watchers.MouseWheelScrollWatcher.CurrentValue; - this.Watchers.MouseWheelScrollWatcher.Reset(); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: mouse wheel scrolled to {now}.", LogLevel.Trace); - events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, was, now)); + this.Monitor.Log($"Events: mouse wheel scrolled to {state.MouseWheelScroll.New}.", LogLevel.Trace); + events.MouseWheelScrolled.Raise(new MouseWheelScrolledEventArgs(cursor, state.MouseWheelScroll.Old, state.MouseWheelScroll.New)); } - else - this.Watchers.MouseWheelScrollWatcher.Reset(); - } - - // raise input button events - foreach (var pair in inputState.ActiveButtons) - { - SButton button = pair.Key; - InputStatus status = pair.Value; - if (status == InputStatus.Pressed) + // raise input button events + foreach (var pair in inputState.ActiveButtons) { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); + SButton button = pair.Key; + InputStatus status = pair.Value; - events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); - -#if !SMAPI_3_0_STRICT - // legacy events - events.Legacy_ButtonPressed.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); - if (button.TryGetKeyboard(out Keys key)) - { - if (key != Keys.None) - events.Legacy_KeyPressed.Raise(new EventArgsKeyPressed(key)); - } - else if (button.TryGetController(out Buttons controllerButton)) + if (status == InputStatus.Pressed) { - if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - events.Legacy_ControllerTriggerPressed.Raise(new EventArgsControllerTriggerPressed(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); - else - events.Legacy_ControllerButtonPressed.Raise(new EventArgsControllerButtonPressed(PlayerIndex.One, controllerButton)); - } -#endif - } - else if (status == InputStatus.Released) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); - - events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} pressed.", LogLevel.Trace); -#if !SMAPI_3_0_STRICT - // legacy events - events.Legacy_ButtonReleased.Raise(new EventArgsInput(button, cursor, inputState.SuppressButtons)); - if (button.TryGetKeyboard(out Keys key)) - { - if (key != Keys.None) - events.Legacy_KeyReleased.Raise(new EventArgsKeyPressed(key)); + events.ButtonPressed.Raise(new ButtonPressedEventArgs(button, cursor, inputState)); } - else if (button.TryGetController(out Buttons controllerButton)) + else if (status == InputStatus.Released) { - if (controllerButton == Buttons.LeftTrigger || controllerButton == Buttons.RightTrigger) - events.Legacy_ControllerTriggerReleased.Raise(new EventArgsControllerTriggerReleased(PlayerIndex.One, controllerButton, controllerButton == Buttons.LeftTrigger ? inputState.RealController.Triggers.Left : inputState.RealController.Triggers.Right)); - else - events.Legacy_ControllerButtonReleased.Raise(new EventArgsControllerButtonReleased(PlayerIndex.One, controllerButton)); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: button {button} released.", LogLevel.Trace); + + events.ButtonReleased.Raise(new ButtonReleasedEventArgs(button, cursor, inputState)); } -#endif } } - -#if !SMAPI_3_0_STRICT - // raise legacy state-changed events - if (inputState.RealKeyboard != previousInputState.RealKeyboard) - events.Legacy_KeyboardChanged.Raise(new EventArgsKeyboardStateChanged(previousInputState.RealKeyboard, inputState.RealKeyboard)); - if (inputState.RealMouse != previousInputState.RealMouse) - events.Legacy_MouseChanged.Raise(new EventArgsMouseStateChanged(previousInputState.RealMouse, inputState.RealMouse, new Point((int)previousInputState.CursorPosition.ScreenPixels.X, (int)previousInputState.CursorPosition.ScreenPixels.Y), new Point((int)inputState.CursorPosition.ScreenPixels.X, (int)inputState.CursorPosition.ScreenPixels.Y))); -#endif } - } - /********* - ** Menu events - *********/ - if (this.Watchers.ActiveMenuWatcher.IsChanged) - { - IClickableMenu was = this.Watchers.ActiveMenuWatcher.PreviousValue; - IClickableMenu now = this.Watchers.ActiveMenuWatcher.CurrentValue; - this.Watchers.ActiveMenuWatcher.Reset(); // reset here so a mod changing the menu will be raised as a new event afterwards - - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: menu changed from {was?.GetType().FullName ?? "none"} to {now?.GetType().FullName ?? "none"}.", LogLevel.Trace); - - // raise menu events - events.MenuChanged.Raise(new MenuChangedEventArgs(was, now)); -#if !SMAPI_3_0_STRICT - if (now != null) - events.Legacy_MenuChanged.Raise(new EventArgsClickableMenuChanged(was, now)); - else - events.Legacy_MenuClosed.Raise(new EventArgsClickableMenuClosed(was)); -#endif - } + /********* + ** Menu events + *********/ + if (state.ActiveMenu.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: menu changed from {state.ActiveMenu.Old?.GetType().FullName ?? "none"} to {state.ActiveMenu.New?.GetType().FullName ?? "none"}.", LogLevel.Trace); - /********* - ** World & player events - *********/ - if (Context.IsWorldReady) - { - bool raiseWorldEvents = !this.Watchers.SaveIdWatcher.IsChanged; // don't report changes from unloaded => loaded + // raise menu events + events.MenuChanged.Raise(new MenuChangedEventArgs(state.ActiveMenu.Old, state.ActiveMenu.New)); + } - // raise location changes - if (this.Watchers.LocationsWatcher.IsChanged) + /********* + ** World & player events + *********/ + if (Context.IsWorldReady) { + bool raiseWorldEvents = !state.SaveID.IsChanged; // don't report changes from unloaded => loaded + // location list changes - if (this.Watchers.LocationsWatcher.IsLocationListChanged) + if (state.Locations.LocationList.IsChanged && (events.LocationListChanged.HasListeners() || this.Monitor.IsVerbose)) { - GameLocation[] added = this.Watchers.LocationsWatcher.Added.ToArray(); - GameLocation[] removed = this.Watchers.LocationsWatcher.Removed.ToArray(); - this.Watchers.LocationsWatcher.ResetLocationList(); + var added = state.Locations.LocationList.Added.ToArray(); + var removed = state.Locations.LocationList.Removed.ToArray(); if (this.Monitor.IsVerbose) { @@ -711,224 +625,128 @@ namespace StardewModdingAPI.Framework } events.LocationListChanged.Raise(new LocationListChangedEventArgs(added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_LocationsChanged.Raise(new EventArgsLocationsChanged(added, removed)); -#endif } // raise location contents changed if (raiseWorldEvents) { - foreach (LocationTracker watcher in this.Watchers.LocationsWatcher.Locations) + foreach (LocationSnapshot locState in state.Locations.Locations) { + var location = locState.Location; + // buildings changed - if (watcher.BuildingsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - Building[] added = watcher.BuildingsWatcher.Added.ToArray(); - Building[] removed = watcher.BuildingsWatcher.Removed.ToArray(); - watcher.BuildingsWatcher.Reset(); - - events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_BuildingsChanged.Raise(new EventArgsLocationBuildingsChanged(location, added, removed)); -#endif - } + if (locState.Buildings.IsChanged) + events.BuildingListChanged.Raise(new BuildingListChangedEventArgs(location, locState.Buildings.Added, locState.Buildings.Removed)); // debris changed - if (watcher.DebrisWatcher.IsChanged) - { - GameLocation location = watcher.Location; - Debris[] added = watcher.DebrisWatcher.Added.ToArray(); - Debris[] removed = watcher.DebrisWatcher.Removed.ToArray(); - watcher.DebrisWatcher.Reset(); - - events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, added, removed)); - } + if (locState.Debris.IsChanged) + events.DebrisListChanged.Raise(new DebrisListChangedEventArgs(location, locState.Debris.Added, locState.Debris.Removed)); // large terrain features changed - if (watcher.LargeTerrainFeaturesWatcher.IsChanged) - { - GameLocation location = watcher.Location; - LargeTerrainFeature[] added = watcher.LargeTerrainFeaturesWatcher.Added.ToArray(); - LargeTerrainFeature[] removed = watcher.LargeTerrainFeaturesWatcher.Removed.ToArray(); - watcher.LargeTerrainFeaturesWatcher.Reset(); - - events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, added, removed)); - } + if (locState.LargeTerrainFeatures.IsChanged) + events.LargeTerrainFeatureListChanged.Raise(new LargeTerrainFeatureListChangedEventArgs(location, locState.LargeTerrainFeatures.Added, locState.LargeTerrainFeatures.Removed)); // NPCs changed - if (watcher.NpcsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - NPC[] added = watcher.NpcsWatcher.Added.ToArray(); - NPC[] removed = watcher.NpcsWatcher.Removed.ToArray(); - watcher.NpcsWatcher.Reset(); - - events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, added, removed)); - } + if (locState.Npcs.IsChanged) + events.NpcListChanged.Raise(new NpcListChangedEventArgs(location, locState.Npcs.Added, locState.Npcs.Removed)); // objects changed - if (watcher.ObjectsWatcher.IsChanged) - { - GameLocation location = watcher.Location; - KeyValuePair<Vector2, SObject>[] added = watcher.ObjectsWatcher.Added.ToArray(); - KeyValuePair<Vector2, SObject>[] removed = watcher.ObjectsWatcher.Removed.ToArray(); - watcher.ObjectsWatcher.Reset(); - - events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, added, removed)); -#if !SMAPI_3_0_STRICT - events.Legacy_ObjectsChanged.Raise(new EventArgsLocationObjectsChanged(location, added, removed)); -#endif - } + if (locState.Objects.IsChanged) + events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); // terrain features changed - if (watcher.TerrainFeaturesWatcher.IsChanged) - { - GameLocation location = watcher.Location; - KeyValuePair<Vector2, TerrainFeature>[] added = watcher.TerrainFeaturesWatcher.Added.ToArray(); - KeyValuePair<Vector2, TerrainFeature>[] removed = watcher.TerrainFeaturesWatcher.Removed.ToArray(); - watcher.TerrainFeaturesWatcher.Reset(); - - events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, added, removed)); - } + if (locState.TerrainFeatures.IsChanged) + events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); } } - else - this.Watchers.LocationsWatcher.Reset(); - } - - // raise time changed - if (raiseWorldEvents && this.Watchers.TimeWatcher.IsChanged) - { - int was = this.Watchers.TimeWatcher.PreviousValue; - int now = this.Watchers.TimeWatcher.CurrentValue; - this.Watchers.TimeWatcher.Reset(); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: time changed from {was} to {now}.", LogLevel.Trace); + // raise time changed + if (raiseWorldEvents && state.Time.IsChanged) + events.TimeChanged.Raise(new TimeChangedEventArgs(state.Time.Old, state.Time.New)); - events.TimeChanged.Raise(new TimeChangedEventArgs(was, now)); -#if !SMAPI_3_0_STRICT - events.Legacy_TimeOfDayChanged.Raise(new EventArgsIntChanged(was, now)); -#endif - } - else - this.Watchers.TimeWatcher.Reset(); + // raise player events + if (raiseWorldEvents) + { + PlayerSnapshot playerState = state.CurrentPlayer; + Farmer player = playerState.Player; - // raise player events - if (raiseWorldEvents) - { - PlayerTracker playerTracker = this.Watchers.CurrentPlayerTracker; + // raise current location changed + if (playerState.Location.IsChanged) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Context: set location to {playerState.Location.New}.", LogLevel.Trace); - // raise current location changed - if (playerTracker.TryGetNewLocation(out GameLocation newLocation)) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: set location to {newLocation.Name}.", LogLevel.Trace); + events.Warped.Raise(new WarpedEventArgs(player, playerState.Location.Old, playerState.Location.New)); + } - GameLocation oldLocation = playerTracker.LocationWatcher.PreviousValue; - events.Warped.Raise(new WarpedEventArgs(playerTracker.Player, oldLocation, newLocation)); -#if !SMAPI_3_0_STRICT - events.Legacy_PlayerWarped.Raise(new EventArgsPlayerWarped(oldLocation, newLocation)); -#endif - } + // raise player leveled up a skill + foreach (var pair in playerState.Skills) + { + if (!pair.Value.IsChanged) + continue; - // raise player leveled up a skill - foreach (KeyValuePair<SkillType, IValueWatcher<int>> pair in playerTracker.GetChangedSkills()) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.PreviousValue} to {pair.Value.CurrentValue}.", LogLevel.Trace); + if (this.Monitor.IsVerbose) + this.Monitor.Log($"Events: player skill '{pair.Key}' changed from {pair.Value.Old} to {pair.Value.New}.", LogLevel.Trace); - events.LevelChanged.Raise(new LevelChangedEventArgs(playerTracker.Player, pair.Key, pair.Value.PreviousValue, pair.Value.CurrentValue)); -#if !SMAPI_3_0_STRICT - events.Legacy_LeveledUp.Raise(new EventArgsLevelUp((EventArgsLevelUp.LevelType)pair.Key, pair.Value.CurrentValue)); -#endif - } + events.LevelChanged.Raise(new LevelChangedEventArgs(player, pair.Key, pair.Value.Old, pair.Value.New)); + } - // raise player inventory changed - ItemStackChange[] changedItems = playerTracker.GetInventoryChanges().ToArray(); - if (changedItems.Any()) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); - events.InventoryChanged.Raise(new InventoryChangedEventArgs(playerTracker.Player, changedItems)); -#if !SMAPI_3_0_STRICT - events.Legacy_InventoryChanged.Raise(new EventArgsInventoryChanged(Game1.player.Items, changedItems)); -#endif + // raise player inventory changed + ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray(); + if (changedItems.Any()) + { + if (this.Monitor.IsVerbose) + this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); + events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, changedItems)); + } } + } - // raise mine level changed - if (playerTracker.TryGetNewMineLevel(out int mineLevel)) - { - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Context: mine level changed to {mineLevel}.", LogLevel.Trace); -#if !SMAPI_3_0_STRICT - events.Legacy_MineLevelChanged.Raise(new EventArgsMineLevelChanged(playerTracker.MineLevelWatcher.PreviousValue, mineLevel)); -#endif - } + /********* + ** Game update + *********/ + // game launched + bool isFirstTick = SGame.TicksElapsed == 0; + if (isFirstTick) + { + Context.IsGameLaunched = true; + events.GameLaunched.Raise(new GameLaunchedEventArgs()); } - this.Watchers.CurrentPlayerTracker?.Reset(); - } - // update save ID watcher - this.Watchers.SaveIdWatcher.Reset(); + // preloaded + if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready && Game1.dayOfMonth != 0) + this.OnLoadStageChanged(LoadStage.Loaded); + } /********* - ** Game update + ** Game update tick *********/ - // game launched - bool isFirstTick = SGame.TicksElapsed == 0; - if (isFirstTick) - events.GameLaunched.Raise(new GameLaunchedEventArgs()); - - // preloaded - if (Context.IsSaveLoaded && Context.LoadStage != LoadStage.Loaded && Context.LoadStage != LoadStage.Ready) - this.OnLoadStageChanged(LoadStage.Loaded); - - // update tick - bool isOneSecond = SGame.TicksElapsed % 60 == 0; - events.UnvalidatedUpdateTicking.RaiseEmpty(); - events.UpdateTicking.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicking.RaiseEmpty(); - try { - this.Input.UpdateSuppression(); - SGame.TicksElapsed++; - base.Update(gameTime); - } - catch (Exception ex) - { - this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + bool isOneSecond = SGame.TicksElapsed % 60 == 0; + events.UnvalidatedUpdateTicking.RaiseEmpty(); + events.UpdateTicking.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicking.RaiseEmpty(); + try + { + this.Input.UpdateSuppression(); + SGame.TicksElapsed++; + base.Update(gameTime); + } + catch (Exception ex) + { + this.MonitorForGame.Log($"An error occured in the base update loop: {ex.GetLogSummary()}", LogLevel.Error); + } + + events.UnvalidatedUpdateTicked.RaiseEmpty(); + events.UpdateTicked.RaiseEmpty(); + if (isOneSecond) + events.OneSecondUpdateTicked.RaiseEmpty(); } - events.UnvalidatedUpdateTicked.RaiseEmpty(); - events.UpdateTicked.RaiseEmpty(); - if (isOneSecond) - events.OneSecondUpdateTicked.RaiseEmpty(); /********* ** Update events *********/ -#if !SMAPI_3_0_STRICT - events.Legacy_UnvalidatedUpdateTick.Raise(); - if (isFirstTick) - events.Legacy_FirstUpdateTick.Raise(); - events.Legacy_UpdateTick.Raise(); - if (SGame.TicksElapsed % 2 == 0) - events.Legacy_SecondUpdateTick.Raise(); - if (SGame.TicksElapsed % 4 == 0) - events.Legacy_FourthUpdateTick.Raise(); - if (SGame.TicksElapsed % 8 == 0) - events.Legacy_EighthUpdateTick.Raise(); - if (SGame.TicksElapsed % 15 == 0) - events.Legacy_QuarterSecondTick.Raise(); - if (SGame.TicksElapsed % 30 == 0) - events.Legacy_HalfSecondTick.Raise(); - if (SGame.TicksElapsed % 60 == 0) - events.Legacy_OneSecondTick.Raise(); -#endif - this.UpdateCrashTimer.Reset(); } catch (Exception ex) @@ -938,18 +756,20 @@ namespace StardewModdingAPI.Framework // exit if irrecoverable if (!this.UpdateCrashTimer.Decrement()) - this.Monitor.ExitGameImmediately("the game crashed when updating, and SMAPI was unable to recover the game."); + this.ExitGameImmediately("The game crashed when updating, and SMAPI was unable to recover the game."); } } /// <summary>The method called to draw everything to the screen.</summary> /// <param name="gameTime">A snapshot of the game timing state.</param> - protected override void Draw(GameTime gameTime) + /// <param name="target_screen">The render target, if any.</param> + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] + protected override void _draw(GameTime gameTime, RenderTarget2D target_screen) { Context.IsInDrawLoop = true; try { - this.DrawImpl(gameTime); + this.DrawImpl(gameTime, target_screen); this.DrawCrashTimer.Reset(); } catch (Exception ex) @@ -960,7 +780,7 @@ namespace StardewModdingAPI.Framework // exit if irrecoverable if (!this.DrawCrashTimer.Decrement()) { - this.Monitor.ExitGameImmediately("the game crashed when drawing, and SMAPI was unable to recover the game."); + this.ExitGameImmediately("The game crashed when drawing, and SMAPI was unable to recover the game."); return; } @@ -983,8 +803,10 @@ namespace StardewModdingAPI.Framework /// <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> /// <remarks>This implementation is identical to <see cref="Game1.Draw"/>, except for try..catch around menu draw code, private field references replaced by wrappers, and added events.</remarks> [SuppressMessage("ReSharper", "CompareOfFloatsByEqualityOperator", Justification = "copied from game code as-is")] + [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "LocalVariableHidesMember", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "PossibleLossOfFraction", Justification = "copied from game code as-is")] [SuppressMessage("ReSharper", "RedundantArgumentDefaultValue", Justification = "copied from game code as-is")] @@ -993,19 +815,21 @@ namespace StardewModdingAPI.Framework [SuppressMessage("ReSharper", "RedundantTypeArgumentsOfMethod", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidNetField", Justification = "copied from game code as-is")] [SuppressMessage("SMAPI.CommonErrors", "AvoidImplicitNetFieldCast", Justification = "copied from game code as-is")] - private void DrawImpl(GameTime gameTime) + private void DrawImpl(GameTime gameTime, RenderTarget2D target_screen) { var events = this.Events; if (Game1._newDayTask != null) - this.GraphicsDevice.Clear(this.bgColor); + { + this.GraphicsDevice.Clear(Game1.bgColor); + } else { - if ((double)Game1.options.zoomLevel != 1.0) - this.GraphicsDevice.SetRenderTarget(this.screen); + if (target_screen != null) + this.GraphicsDevice.SetRenderTarget(target_screen); if (this.IsSaving) { - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); IClickableMenu activeClickableMenu = Game1.activeClickableMenu; if (activeClickableMenu != null) { @@ -1014,14 +838,8 @@ namespace StardewModdingAPI.Framework try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1029,10 +847,6 @@ namespace StardewModdingAPI.Framework activeClickableMenu.exitThisMenu(); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif - Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -1041,11 +855,11 @@ namespace StardewModdingAPI.Framework Game1.overlayMenu.draw(Game1.spriteBatch); Game1.spriteBatch.End(); } - this.renderScreenBuffer(); + this.renderScreenBuffer(target_screen); } else { - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); if (Game1.activeClickableMenu != null && Game1.options.showMenuBackground && Game1.activeClickableMenu.showWithoutTransparencyIfOptionIsSet()) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1055,14 +869,8 @@ namespace StardewModdingAPI.Framework { Game1.activeClickableMenu.drawBackground(Game1.spriteBatch); events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1070,17 +878,14 @@ namespace StardewModdingAPI.Framework Game1.activeClickableMenu.exitThisMenu(); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel != 1.0) + if (target_screen != null) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } if (Game1.overlayMenu == null) @@ -1093,34 +898,46 @@ namespace StardewModdingAPI.Framework { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); events.Rendering.RaiseEmpty(); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Color.HotPink); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Color(0, (int)byte.MaxValue, 0)); - Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Color.White); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3685"), new Vector2(16f, 16f), Microsoft.Xna.Framework.Color.HotPink); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3686"), new Vector2(16f, 32f), new Microsoft.Xna.Framework.Color(0, (int)byte.MaxValue, 0)); + Game1.spriteBatch.DrawString(Game1.dialogueFont, Game1.parseText(Game1.errorMessage, Game1.dialogueFont, Game1.graphics.GraphicsDevice.Viewport.Width), new Vector2(16f, 48f), Microsoft.Xna.Framework.Color.White); events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); } else if (Game1.currentMinigame != null) { + int batchEnds = 0; + + if (events.Rendering.HasListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendering.RaiseEmpty(); + Game1.spriteBatch.End(); + } Game1.currentMinigame.draw(Game1.spriteBatch); if (Game1.globalFade && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, Game1.graphics.GraphicsDevice.Viewport.Bounds, Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha)); Game1.spriteBatch.End(); } this.drawOverlays(Game1.spriteBatch); -#if !SMAPI_3_0_STRICT - this.RaisePostRender(needsNewBatch: true); -#endif - if ((double)Game1.options.zoomLevel == 1.0) + if (target_screen == null) + { + if (++batchEnds == 1 && events.Rendered.HasListeners()) + { + Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); + events.Rendered.RaiseEmpty(); + Game1.spriteBatch.End(); + } return; + } this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + if (++batchEnds == 1) + events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); } else if (Game1.showingEndOfNightStuff) @@ -1132,14 +949,8 @@ namespace StardewModdingAPI.Framework try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1150,12 +961,12 @@ namespace StardewModdingAPI.Framework events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel == 1.0) + if (target_screen == null) return; this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } else if (Game1.gameMode == (byte)6 || Game1.gameMode == (byte)3 && Game1.currentLocation == null) @@ -1168,20 +979,20 @@ namespace StardewModdingAPI.Framework string str2 = Game1.content.LoadString("Strings\\StringsFromCSFiles:Game1.cs.3688"); string s = str2 + str1; string str3 = str2 + "... "; - int widthOfString = SpriteText.getWidthOfString(str3); + int widthOfString = SpriteText.getWidthOfString(str3, 999999); int height = 64; int x = 64; int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height; - SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1); + SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1, SpriteText.ScrollTextAlignment.Left); events.Rendered.RaiseEmpty(); Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - if ((double)Game1.options.zoomLevel != 1.0) + if (target_screen != null) { this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone); - Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)target_screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(target_screen.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f); Game1.spriteBatch.End(); } if (Game1.overlayMenu != null) @@ -1196,7 +1007,6 @@ namespace StardewModdingAPI.Framework { byte batchOpens = 0; // used for rendering event - Microsoft.Xna.Framework.Rectangle rectangle; Viewport viewport; if (Game1.gameMode == (byte)0) { @@ -1209,29 +1019,37 @@ namespace StardewModdingAPI.Framework if (Game1.drawLighting) { this.GraphicsDevice.SetRenderTarget(Game1.lightmap); - this.GraphicsDevice.Clear(Color.White * 0.0f); + this.GraphicsDevice.Clear(Microsoft.Xna.Framework.Color.White * 0.0f); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.NonPremultiplied, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (++batchOpens == 1) events.Rendering.RaiseEmpty(); - Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, Game1.currentLocation.Name.StartsWith("UndergroundMine") ? Game1.mine.getLightingColor(gameTime) : (Game1.ambientLight.Equals(Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight)); + Microsoft.Xna.Framework.Color color = !Game1.currentLocation.Name.StartsWith("UndergroundMine") || !(Game1.currentLocation is MineShaft) ? (Game1.ambientLight.Equals(Microsoft.Xna.Framework.Color.White) || Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) ? Game1.outdoorLight : Game1.ambientLight) : (Game1.currentLocation as MineShaft).getLightingColor(gameTime); + Game1.spriteBatch.Draw(Game1.staminaRect, Game1.lightmap.Bounds, color); for (int index = 0; index < Game1.currentLightSources.Count; ++index) { - if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0))) - Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), (Color)((NetFieldBase<Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color), 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), (float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + LightSource lightSource = Game1.currentLightSources.ElementAt<LightSource>(index); + if (!Game1.isRaining && !Game1.isDarkOut() || lightSource.lightContext.Value != LightSource.LightContext.WindowLight) + { + if (lightSource.PlayerID != 0L && lightSource.PlayerID != Game1.player.UniqueMultiplayerID) + { + Farmer farmerMaybeOffline = Game1.getFarmerMaybeOffline(lightSource.PlayerID); + if (farmerMaybeOffline == null || farmerMaybeOffline.currentLocation != null && farmerMaybeOffline.currentLocation.Name != Game1.currentLocation.Name || (bool)((NetFieldBase<bool, NetBool>)farmerMaybeOffline.hidden)) + continue; + } + if (Utility.isOnScreen((Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position), (int)((double)(float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) * 64.0 * 4.0))) + Game1.spriteBatch.Draw(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture, Game1.GlobalToLocal(Game1.viewport, (Vector2)((NetFieldBase<Vector2, NetVector2>)Game1.currentLightSources.ElementAt<LightSource>(index).position)) / (float)(Game1.options.lightingQuality / 2), new Microsoft.Xna.Framework.Rectangle?(Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds), (Microsoft.Xna.Framework.Color)((NetFieldBase<Microsoft.Xna.Framework.Color, NetColor>)Game1.currentLightSources.ElementAt<LightSource>(index).color), 0.0f, new Vector2((float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.X, (float)Game1.currentLightSources.ElementAt<LightSource>(index).lightTexture.Bounds.Center.Y), (float)((NetFieldBase<float, NetFloat>)Game1.currentLightSources.ElementAt<LightSource>(index).radius) / (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 0.9f); + } } Game1.spriteBatch.End(); - this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screen); + this.GraphicsDevice.SetRenderTarget(target_screen); } if (Game1.bloomDay && Game1.bloom != null) Game1.bloom.BeginDraw(); - this.GraphicsDevice.Clear(this.bgColor); + this.GraphicsDevice.Clear(Game1.bgColor); Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (++batchOpens == 1) events.Rendering.RaiseEmpty(); events.RenderingWorld.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderEvent.Raise(); -#endif if (Game1.background != null) Game1.background.draw(Game1.spriteBatch); Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); @@ -1261,7 +1079,7 @@ namespace StardewModdingAPI.Framework foreach (NPC character in Game1.currentLocation.characters) { if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && (!character.IsInvisible && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); } } else @@ -1269,32 +1087,17 @@ namespace StardewModdingAPI.Framework foreach (NPC actor in Game1.CurrentEvent.actors) { if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : (actor.Sprite.SpriteHeight <= 16 ? -4 : 12))))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } foreach (Farmer farmerShadow in this._farmerShadows) { - if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Color white = Color.White; - double num1 = 0.0; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - int num3 = 0; - double num4 = 0.0; - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); - } + if (!Game1.multiplayer.isDisconnecting(farmerShadow.UniqueMultiplayerID) && !(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5)), SpriteEffects.None, 0.0f); } } - Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); + Layer layer = Game1.currentLocation.Map.GetLayer("Buildings"); + layer.Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); Game1.mapDisplayDevice.EndScene(); Game1.spriteBatch.End(); Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); @@ -1304,8 +1107,8 @@ namespace StardewModdingAPI.Framework { foreach (NPC character in Game1.currentLocation.characters) { - if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); + if (!(bool)((NetFieldBase<bool, NetBool>)character.swimming) && !character.HideShadow && (!(bool)((NetFieldBase<bool, NetBool>)character.isInvisible) && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(character.getTileLocation()))) + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, character.Position + new Vector2((float)(character.Sprite.SpriteWidth * 4) / 2f, (float)(character.GetBoundingBox().Height + (character.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)character.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)character.scale), SpriteEffects.None, Math.Max(0.0f, (float)character.getStandingY() / 10000f) - 1E-06f); } } else @@ -1313,36 +1116,31 @@ namespace StardewModdingAPI.Framework foreach (NPC actor in Game1.CurrentEvent.actors) { if (!(bool)((NetFieldBase<bool, NetBool>)actor.swimming) && !actor.HideShadow && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(actor.getTileLocation())) - Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f); } } foreach (Farmer farmerShadow in this._farmerShadows) { + float layerDepth = Math.Max(0.0001f, farmerShadow.getDrawLayer() + 0.00011f) - 0.0001f; if (!(bool)((NetFieldBase<bool, NetBool>)farmerShadow.swimming) && !farmerShadow.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmerShadow.getTileLocation()))) - { - SpriteBatch spriteBatch = Game1.spriteBatch; - Texture2D shadowTexture = Game1.shadowTexture; - Vector2 local = Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)); - Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds); - Color white = Color.White; - double num1 = 0.0; - Microsoft.Xna.Framework.Rectangle bounds = Game1.shadowTexture.Bounds; - double x = (double)bounds.Center.X; - bounds = Game1.shadowTexture.Bounds; - double y = (double)bounds.Center.Y; - Vector2 origin = new Vector2((float)x, (float)y); - double num2 = 4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5); - int num3 = 0; - double num4 = 0.0; - spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num1, origin, (float)num2, (SpriteEffects)num3, (float)num4); - } + Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(farmerShadow.Position + new Vector2(32f, 24f)), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 - (!farmerShadow.running && !farmerShadow.UsingTool || farmerShadow.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmerShadow.FarmerSprite.CurrentFrame]) * 0.5)), SpriteEffects.None, layerDepth); } } if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null)) Game1.currentLocation.currentEvent.draw(Game1.spriteBatch); if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm")) - Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0)); + Game1.spriteBatch.Draw(Game1.player.currentUpgrade.workerTexture, Game1.GlobalToLocal(Game1.viewport, Game1.player.currentUpgrade.positionOfCarpenter), new Microsoft.Xna.Framework.Rectangle?(Game1.player.currentUpgrade.getSourceRectangle()), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, (float)(((double)Game1.player.currentUpgrade.positionOfCarpenter.Y + 48.0) / 10000.0)); Game1.currentLocation.draw(Game1.spriteBatch); + foreach (Vector2 key in Game1.crabPotOverlayTiles.Keys) + { + Tile tile = layer.Tiles[(int)key.X, (int)key.Y]; + if (tile != null) + { + Vector2 local = Game1.GlobalToLocal(Game1.viewport, key * 64f); + Location location = new Location((int)local.X, (int)local.Y); + Game1.mapDisplayDevice.DrawTile(tile, location, (float)(((double)key.Y * 64.0 - 1.0) / 10000.0)); + } + } if (Game1.eventUp && Game1.currentLocation.currentEvent != null) { string messageToScreen = Game1.currentLocation.currentEvent.messageToScreen; @@ -1352,12 +1150,12 @@ namespace StardewModdingAPI.Framework if (Game1.currentLocation.Name.Equals("Farm")) this.drawFarmBuildings(); if (Game1.tvStation >= 0) - Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); + Game1.spriteBatch.Draw(Game1.tvStationTexture, Game1.GlobalToLocal(Game1.viewport, new Vector2(400f, 160f)), new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(Game1.tvStation * 24, 0, 24, 15)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, 1E-08f); if (Game1.panMode) { - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Color.Lime * 0.75f); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((int)Math.Floor((double)(Game1.getOldMouseX() + Game1.viewport.X) / 64.0) * 64 - Game1.viewport.X, (int)Math.Floor((double)(Game1.getOldMouseY() + Game1.viewport.Y) / 64.0) * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Lime * 0.75f); foreach (Warp warp in (NetList<Warp, NetRef<Warp>>)Game1.currentLocation.warps) - Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Color.Red * 0.75f); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle(warp.X * 64 - Game1.viewport.X, warp.Y * 64 - Game1.viewport.Y, 64, 64), Microsoft.Xna.Framework.Color.Red * 0.75f); } Game1.mapDisplayDevice.BeginScene(Game1.spriteBatch); Game1.currentLocation.Map.GetLayer("Front").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4); @@ -1367,29 +1165,8 @@ namespace StardewModdingAPI.Framework Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.displayFarmer && Game1.player.ActiveObject != null && ((bool)((NetFieldBase<bool, NetBool>)Game1.player.ActiveObject.bigCraftable) && this.checkBigCraftableBoundariesForFrontLayer()) && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null) Game1.drawPlayerHeldObject(Game1.player); - else if (Game1.displayFarmer && Game1.player.ActiveObject != null) - { - if (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) == null || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways")) - { - Layer layer1 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation1 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - Size size1 = Game1.viewport.Size; - if (layer1.PickTile(mapDisplayLocation1, size1) != null) - { - Layer layer2 = Game1.currentLocation.Map.GetLayer("Front"); - rectangle = Game1.player.GetBoundingBox(); - Location mapDisplayLocation2 = new Location(rectangle.Right, (int)Game1.player.Position.Y - 38); - Size size2 = Game1.viewport.Size; - if (layer2.PickTile(mapDisplayLocation2, size2).TileIndexProperties.ContainsKey("FrontAlways")) - goto label_129; - } - else - goto label_129; - } + else if (Game1.displayFarmer && Game1.player.ActiveObject != null && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location((int)Game1.player.Position.X, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways") || Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && !Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.GetBoundingBox().Right, (int)Game1.player.Position.Y - 38), Game1.viewport.Size).TileIndexProperties.ContainsKey("FrontAlways"))) Game1.drawPlayerHeldObject(Game1.player); - } - label_129: if ((Game1.player.UsingTool || Game1.pickingTool) && Game1.player.CurrentTool != null && ((!Game1.player.CurrentTool.Name.Equals("Seeds") || Game1.pickingTool) && (Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), (int)Game1.player.Position.Y - 38), Game1.viewport.Size) != null && Game1.currentLocation.Map.GetLayer("Front").PickTile(new Location(Game1.player.getStandingX(), Game1.player.getStandingY()), Game1.viewport.Size) == null))) Game1.drawTool(Game1.player); if (Game1.currentLocation.Map.GetLayer("AlwaysFront") != null) @@ -1400,7 +1177,7 @@ namespace StardewModdingAPI.Framework } if ((double)Game1.toolHold > 400.0 && Game1.player.CurrentTool.UpgradeLevel >= 1 && Game1.player.canReleaseTool) { - Color color = Color.White; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White; switch ((int)((double)Game1.toolHold / 600.0) + 2) { case 1: @@ -1416,14 +1193,10 @@ namespace StardewModdingAPI.Framework color = Tool.iridiumColor; break; } - Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Color.Black); + Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X - 2, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64) - 2, (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607) + 4, 12), Microsoft.Xna.Framework.Color.Black); Game1.spriteBatch.Draw(Game1.littleEffect, new Microsoft.Xna.Framework.Rectangle((int)Game1.player.getLocalPosition(Game1.viewport).X, (int)Game1.player.getLocalPosition(Game1.viewport).Y - (Game1.player.CurrentTool.Name.Equals("Watering Can") ? 0 : 64), (int)((double)Game1.toolHold % 600.0 * 0.0799999982118607), 8), color); } - if (Game1.isDebrisWeather && Game1.currentLocation.IsOutdoors && (!(bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.ignoreDebrisWeather) && !Game1.currentLocation.Name.Equals("Desert")) && Game1.viewport.X > -10) - { - foreach (WeatherDebris weatherDebris in Game1.debrisWeather) - weatherDebris.draw(Game1.spriteBatch); - } + this.drawWeather(gameTime, target_screen); if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); if ((double)Game1.currentLocation.LightLevel > 0.0 && Game1.timeOfDay < 2000) @@ -1432,7 +1205,7 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Black * Game1.currentLocation.LightLevel; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * Game1.currentLocation.LightLevel; spriteBatch.Draw(fadeToBlackRect, bounds, color); } if (Game1.screenGlow) @@ -1441,17 +1214,12 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; + Microsoft.Xna.Framework.Color color = Game1.screenGlowColor * Game1.screenGlowAlpha; spriteBatch.Draw(fadeToBlackRect, bounds, color); } Game1.currentLocation.drawAboveAlwaysFrontLayer(Game1.spriteBatch); if (Game1.player.CurrentTool != null && Game1.player.CurrentTool is FishingRod && ((Game1.player.CurrentTool as FishingRod).isTimingCast || (double)(Game1.player.CurrentTool as FishingRod).castingChosenCountdown > 0.0 || ((Game1.player.CurrentTool as FishingRod).fishCaught || (Game1.player.CurrentTool as FishingRod).showingTreasure))) Game1.player.CurrentTool.draw(Game1.spriteBatch); - if (Game1.isRaining && Game1.currentLocation.IsOutdoors && (!Game1.currentLocation.Name.Equals("Desert") && !(Game1.currentLocation is Summit)) && (!Game1.eventUp || Game1.currentLocation.isTileOnMap(new Vector2((float)(Game1.viewport.X / 64), (float)(Game1.viewport.Y / 64))))) - { - for (int index = 0; index < Game1.rainDrops.Length; ++index) - Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White); - } Game1.spriteBatch.End(); Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null); if (Game1.eventUp && Game1.currentLocation.currentEvent != null) @@ -1466,7 +1234,7 @@ namespace StardewModdingAPI.Framework localPosition.Y += 32f; else if (actor.Gender == 1) localPosition.Y += 10f; - Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); + Game1.spriteBatch.Draw(Game1.emoteSpriteSheet, localPosition, new Microsoft.Xna.Framework.Rectangle?(new Microsoft.Xna.Framework.Rectangle(actor.CurrentEmoteIndex * 16 % Game1.emoteSpriteSheet.Width, actor.CurrentEmoteIndex * 16 / Game1.emoteSpriteSheet.Width * 16, 16, 16)), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, 4f, SpriteEffects.None, (float)actor.getStandingY() / 10000f); } } } @@ -1474,14 +1242,14 @@ namespace StardewModdingAPI.Framework if (Game1.drawLighting) { Game1.spriteBatch.Begin(SpriteSortMode.Deferred, this.lightingBlend, SamplerState.LinearClamp, (DepthStencilState)null, (RasterizerState)null); - Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); + Game1.spriteBatch.Draw((Texture2D)Game1.lightmap, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(Game1.lightmap.Bounds), Microsoft.Xna.Framework.Color.White, 0.0f, Vector2.Zero, (float)(Game1.options.lightingQuality / 2), SpriteEffects.None, 1f); if (Game1.isRaining && (bool)((NetFieldBase<bool, NetBool>)Game1.currentLocation.isOutdoors) && !(Game1.currentLocation is Desert)) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.OrangeRed * 0.45f; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.OrangeRed * 0.45f; spriteBatch.Draw(staminaRect, bounds, color); } Game1.spriteBatch.End(); @@ -1497,18 +1265,17 @@ namespace StardewModdingAPI.Framework { int num4 = num3; viewport = Game1.graphics.GraphicsDevice.Viewport; - int width1 = viewport.Width; - if (num4 < width1) + int width = viewport.Width; + if (num4 < width) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; int x = num3; int y = (int)num2; - int width2 = 1; viewport = Game1.graphics.GraphicsDevice.Viewport; int height = viewport.Height; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width2, height); - Color color = Color.Red * 0.5f; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, 1, height); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; spriteBatch.Draw(staminaRect, destinationRectangle, color); num3 += 64; } @@ -1520,8 +1287,8 @@ namespace StardewModdingAPI.Framework { double num4 = (double)num5; viewport = Game1.graphics.GraphicsDevice.Viewport; - double height1 = (double)viewport.Height; - if (num4 < height1) + double height = (double)viewport.Height; + if (num4 < height) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D staminaRect = Game1.staminaRect; @@ -1529,9 +1296,8 @@ namespace StardewModdingAPI.Framework int y = (int)num5; viewport = Game1.graphics.GraphicsDevice.Viewport; int width = viewport.Width; - int height2 = 1; - Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, height2); - Color color = Color.Red * 0.5f; + Microsoft.Xna.Framework.Rectangle destinationRectangle = new Microsoft.Xna.Framework.Rectangle(x, y, width, 1); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Red * 0.5f; spriteBatch.Draw(staminaRect, destinationRectangle, color); num5 += 64f; } @@ -1539,23 +1305,40 @@ namespace StardewModdingAPI.Framework break; } } - if (Game1.currentBillboard != 0) + if (Game1.currentBillboard != 0 && !this.takingMapScreenshot) this.drawBillboard(); - if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && !Game1.HostPaused)) + if (!Game1.eventUp && Game1.farmEvent == null && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!this.takingMapScreenshot && Game1.isOutdoorMapSmallerThanViewport())) + { + SpriteBatch spriteBatch1 = Game1.spriteBatch; + Texture2D fadeToBlackRect1 = Game1.fadeToBlackRect; + int width1 = -Math.Min(Game1.viewport.X, 4096); + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height1 = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(0, 0, width1, height1); + Microsoft.Xna.Framework.Color black1 = Microsoft.Xna.Framework.Color.Black; + spriteBatch1.Draw(fadeToBlackRect1, destinationRectangle1, black1); + SpriteBatch spriteBatch2 = Game1.spriteBatch; + Texture2D fadeToBlackRect2 = Game1.fadeToBlackRect; + int x = -Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64; + viewport = Game1.graphics.GraphicsDevice.Viewport; + int width2 = Math.Min(4096, viewport.Width - (-Game1.viewport.X + Game1.currentLocation.map.Layers[0].LayerWidth * 64)); + viewport = Game1.graphics.GraphicsDevice.Viewport; + int height2 = viewport.Height; + Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x, 0, width2, height2); + Microsoft.Xna.Framework.Color black2 = Microsoft.Xna.Framework.Color.Black; + spriteBatch2.Draw(fadeToBlackRect2, destinationRectangle2, black2); + } + if ((Game1.displayHUD || Game1.eventUp) && (Game1.currentBillboard == 0 && Game1.gameMode == (byte)3) && (!Game1.freezeControls && !Game1.panMode && (!Game1.HostPaused && !this.takingMapScreenshot))) { events.RenderingHud.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderHudEvent.Raise(); -#endif this.drawHUD(); events.RenderedHud.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderHudEvent.Raise(); -#endif } - else if (Game1.activeClickableMenu == null && Game1.farmEvent == null) - Game1.spriteBatch.Draw(Game1.mouseCursors, new Vector2((float)Game1.getOldMouseX(), (float)Game1.getOldMouseY()), new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.mouseCursors, 0, 16, 16)), Color.White, 0.0f, Vector2.Zero, (float)(4.0 + (double)Game1.dialogueButtonScale / 150.0), SpriteEffects.None, 1f); - if (Game1.hudMessages.Count > 0 && (!Game1.eventUp || Game1.isFestival())) + else if (Game1.activeClickableMenu == null) + { + FarmEvent farmEvent = Game1.farmEvent; + } + if (Game1.hudMessages.Count > 0 && !this.takingMapScreenshot) { for (int i = Game1.hudMessages.Count - 1; i >= 0; --i) Game1.hudMessages[i].draw(Game1.spriteBatch, i); @@ -1563,30 +1346,12 @@ namespace StardewModdingAPI.Framework } if (Game1.farmEvent != null) Game1.farmEvent.draw(Game1.spriteBatch); - if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && (Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox))) + if (Game1.dialogueUp && !Game1.nameSelectUp && !Game1.messagePause && ((Game1.activeClickableMenu == null || !(Game1.activeClickableMenu is DialogueBox)) && !this.takingMapScreenshot)) this.drawDialogueBox(); - if (Game1.progressBar) + if (Game1.progressBar && !this.takingMapScreenshot) { - SpriteBatch spriteBatch1 = Game1.spriteBatch; - Texture2D fadeToBlackRect = Game1.fadeToBlackRect; - int x1 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y1 = rectangle.Bottom - 128; - int dialogueWidth = Game1.dialogueWidth; - int height1 = 32; - Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1); - Color lightGray = Color.LightGray; - spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray); - SpriteBatch spriteBatch2 = Game1.spriteBatch; - Texture2D staminaRect = Game1.staminaRect; - int x2 = (Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2; - rectangle = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea(); - int y2 = rectangle.Bottom - 128; - int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth); - int height2 = 32; - Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2); - Color dimGray = Color.DimGray; - spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray); + Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Microsoft.Xna.Framework.Color.LightGray); + Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth), 32), Microsoft.Xna.Framework.Color.DimGray); } if (Game1.eventUp && Game1.currentLocation != null && Game1.currentLocation.currentEvent != null) Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch); @@ -1596,19 +1361,19 @@ namespace StardewModdingAPI.Framework Texture2D staminaRect = Game1.staminaRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Blue * 0.2f; + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Blue * 0.2f; spriteBatch.Draw(staminaRect, bounds, color); } - if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && (!Game1.nameSelectUp || Game1.messagePause)) + if ((Game1.fadeToBlack || Game1.globalFade) && !Game1.menuUp && ((!Game1.nameSelectUp || Game1.messagePause) && !this.takingMapScreenshot)) { SpriteBatch spriteBatch = Game1.spriteBatch; Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.Black * (Game1.gameMode == (byte)0 ? 1f - Game1.fadeToBlackAlpha : Game1.fadeToBlackAlpha); spriteBatch.Draw(fadeToBlackRect, bounds, color); } - else if ((double)Game1.flashAlpha > 0.0) + else if ((double)Game1.flashAlpha > 0.0 && !this.takingMapScreenshot) { if (Game1.options.screenFlash) { @@ -1616,15 +1381,18 @@ namespace StardewModdingAPI.Framework Texture2D fadeToBlackRect = Game1.fadeToBlackRect; viewport = Game1.graphics.GraphicsDevice.Viewport; Microsoft.Xna.Framework.Rectangle bounds = viewport.Bounds; - Color color = Color.White * Math.Min(1f, Game1.flashAlpha); + Microsoft.Xna.Framework.Color color = Microsoft.Xna.Framework.Color.White * Math.Min(1f, Game1.flashAlpha); spriteBatch.Draw(fadeToBlackRect, bounds, color); } Game1.flashAlpha -= 0.1f; } - if ((Game1.messagePause || Game1.globalFade) && Game1.dialogueUp) + if ((Game1.messagePause || Game1.globalFade) && (Game1.dialogueUp && !this.takingMapScreenshot)) this.drawDialogueBox(); - foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) - overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f); + if (!this.takingMapScreenshot) + { + foreach (TemporaryAnimatedSprite overlayTempSprite in Game1.screenOverlayTempSprites) + overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f); + } if (Game1.debugMode) { StringBuilder debugStringBuilder = Game1._debugStringBuilder; @@ -1649,25 +1417,23 @@ namespace StardewModdingAPI.Framework debugStringBuilder.Append(","); debugStringBuilder.Append(Game1.getMouseY()); debugStringBuilder.Append(Environment.NewLine); - debugStringBuilder.Append("debugOutput: "); + debugStringBuilder.Append(" mouseWorldPosition: "); + debugStringBuilder.Append(Game1.getMouseX() + Game1.viewport.X); + debugStringBuilder.Append(","); + debugStringBuilder.Append(Game1.getMouseY() + Game1.viewport.Y); + debugStringBuilder.Append(" debugOutput: "); debugStringBuilder.Append(Game1.debugOutput); - Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Microsoft.Xna.Framework.Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); } - if (Game1.showKeyHelp) - Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); - if (Game1.activeClickableMenu != null) + if (Game1.showKeyHelp && !this.takingMapScreenshot) + Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Microsoft.Xna.Framework.Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f); + if (Game1.activeClickableMenu != null && !this.takingMapScreenshot) { try { events.RenderingActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPreRenderGuiEvent.Raise(); -#endif Game1.activeClickableMenu.draw(Game1.spriteBatch); events.RenderedActiveMenu.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderGuiEvent.Raise(); -#endif } catch (Exception ex) { @@ -1677,41 +1443,29 @@ namespace StardewModdingAPI.Framework } else if (Game1.farmEvent != null) Game1.farmEvent.drawAboveEverything(Game1.spriteBatch); - if (Game1.HostPaused) + if (Game1.emoteMenu != null && !this.takingMapScreenshot) + Game1.emoteMenu.draw(Game1.spriteBatch); + if (Game1.HostPaused && !this.takingMapScreenshot) { string s = Game1.content.LoadString("Strings\\StringsFromCSFiles:DayTimeMoneyBox.cs.10378"); - SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1); + SpriteText.drawStringWithScrollBackground(Game1.spriteBatch, s, 96, 32, "", 1f, -1, SpriteText.ScrollTextAlignment.Left); } events.Rendered.RaiseEmpty(); -#if !SMAPI_3_0_STRICT - events.Legacy_OnPostRenderEvent.Raise(); -#endif Game1.spriteBatch.End(); this.drawOverlays(Game1.spriteBatch); - this.renderScreenBuffer(); + this.renderScreenBuffer(target_screen); } } } } - /**** - ** Methods - ****/ -#if !SMAPI_3_0_STRICT - /// <summary>Raise the <see cref="GraphicsEvents.OnPostRenderEvent"/> if there are any listeners.</summary> - /// <param name="needsNewBatch">Whether to create a new sprite batch.</param> - private void RaisePostRender(bool needsNewBatch = false) + /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> + /// <param name="message">The fatal log message.</param> + private void ExitGameImmediately(string message) { - if (this.Events.Legacy_OnPostRenderEvent.HasListeners()) - { - if (needsNewBatch) - Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, null, null); - this.Events.Legacy_OnPostRenderEvent.Raise(); - if (needsNewBatch) - Game1.spriteBatch.End(); - } + this.Monitor.LogFatal(message); + this.CancellationToken.Cancel(); } -#endif } } diff --git a/src/SMAPI/Framework/SGameConstructorHack.cs b/src/SMAPI/Framework/SGameConstructorHack.cs index 494bab99..f70dec03 100644 --- a/src/SMAPI/Framework/SGameConstructorHack.cs +++ b/src/SMAPI/Framework/SGameConstructorHack.cs @@ -1,10 +1,11 @@ +using System; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; namespace StardewModdingAPI.Framework { - /// <summary>The static state to use while <see cref="Game1"/> is initialising, which happens before the <see cref="SGame"/> constructor runs.</summary> + /// <summary>The static state to use while <see cref="Game1"/> is initializing, which happens before the <see cref="SGame"/> constructor runs.</summary> internal class SGameConstructorHack { /********* @@ -19,6 +20,9 @@ namespace StardewModdingAPI.Framework /// <summary>Encapsulates SMAPI's JSON file parsing.</summary> public JsonHelper JsonHelper { get; } + /// <summary>A callback to invoke the first time *any* game content manager loads an asset.</summary> + public Action OnLoadingFirstAsset { get; } + /********* ** Public methods @@ -27,11 +31,13 @@ namespace StardewModdingAPI.Framework /// <param name="monitor">Encapsulates monitoring and logging.</param> /// <param name="reflection">Simplifies access to private game code.</param> /// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param> - public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper) + /// <param name="onLoadingFirstAsset">A callback to invoke the first time *any* game content manager loads an asset.</param> + public SGameConstructorHack(IMonitor monitor, Reflector reflection, JsonHelper jsonHelper, Action onLoadingFirstAsset) { this.Monitor = monitor; this.Reflection = reflection; this.JsonHelper = jsonHelper; + this.OnLoadingFirstAsset = onLoadingFirstAsset; } } } diff --git a/src/SMAPI/Framework/SMultiplayer.cs b/src/SMAPI/Framework/SMultiplayer.cs index 0241ef02..e04205c8 100644 --- a/src/SMAPI/Framework/SMultiplayer.cs +++ b/src/SMAPI/Framework/SMultiplayer.cs @@ -9,7 +9,7 @@ using StardewModdingAPI.Events; using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Reflection; -using StardewModdingAPI.Toolkit.Serialisation; +using StardewModdingAPI.Toolkit.Serialization; using StardewValley; using StardewValley.Network; using StardewValley.SDKs; @@ -51,6 +51,9 @@ namespace StardewModdingAPI.Framework /// <summary>A callback to invoke when a mod message is received.</summary> private readonly Action<ModMessageModel> OnModMessageReceived; + /// <summary>Whether to log network traffic.</summary> + private readonly bool LogNetworkTraffic; + /********* ** Accessors @@ -72,7 +75,8 @@ namespace StardewModdingAPI.Framework /// <param name="modRegistry">Tracks the installed mods.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="onModMessageReceived">A callback to invoke when a mod message is received.</param> - public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived) + /// <param name="logNetworkTraffic">Whether to log network traffic.</param> + public SMultiplayer(IMonitor monitor, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, Reflector reflection, Action<ModMessageModel> onModMessageReceived, bool logNetworkTraffic) { this.Monitor = monitor; this.EventManager = eventManager; @@ -80,6 +84,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry = modRegistry; this.Reflection = reflection; this.OnModMessageReceived = onModMessageReceived; + this.LogNetworkTraffic = logNetworkTraffic; } /// <summary>Perform cleanup needed when a multiplayer session ends.</summary> @@ -89,26 +94,8 @@ namespace StardewModdingAPI.Framework this.HostPeer = null; } -#if !SMAPI_3_0_STRICT - /// <summary>Handle sync messages from other players and perform other initial sync logic.</summary> - public override void UpdateEarly() - { - this.EventManager.Legacy_BeforeMainSync.Raise(); - base.UpdateEarly(); - this.EventManager.Legacy_AfterMainSync.Raise(); - } - - /// <summary>Broadcast sync messages to other players and perform other final sync logic.</summary> - public override void UpdateLate(bool forceSync = false) - { - this.EventManager.Legacy_BeforeMainBroadcast.Raise(); - base.UpdateLate(forceSync); - this.EventManager.Legacy_AfterMainBroadcast.Raise(); - } -#endif - - /// <summary>Initialise a client before the game connects to a remote server.</summary> - /// <param name="client">The client to initialise.</param> + /// <summary>Initialize a client before the game connects to a remote server.</summary> + /// <param name="client">The client to initialize.</param> public override Client InitClient(Client client) { switch (client) @@ -131,8 +118,8 @@ namespace StardewModdingAPI.Framework } } - /// <summary>Initialise a server before the game connects to an incoming player.</summary> - /// <param name="server">The server to initialise.</param> + /// <summary>Initialize a server before the game connects to an incoming player.</summary> + /// <param name="server">The server to initialize.</param> public override Server InitServer(Server server) { switch (server) @@ -161,7 +148,7 @@ namespace StardewModdingAPI.Framework /// <param name="resume">Resume sending the underlying message.</param> protected void OnClientSendingMessage(OutgoingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"CLIENT SEND {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -185,7 +172,7 @@ namespace StardewModdingAPI.Framework /// <param name="resume">Process the message using the game's default logic.</param> public void OnServerProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"SERVER RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -201,7 +188,8 @@ namespace StardewModdingAPI.Framework MultiplayerPeer newPeer = new MultiplayerPeer(message.FarmerID, model, sendMessage, isHost: false); if (this.Peers.ContainsKey(message.FarmerID)) { - this.Monitor.Log($"Rejected mod context from farmhand {message.FarmerID}: already received context for that player.", LogLevel.Error); + this.Monitor.Log($"Received mod context from farmhand {message.FarmerID}, but the game didn't see them disconnect. This may indicate issues with the network connection.", LogLevel.Info); + this.Peers.Remove(message.FarmerID); return; } this.AddPeer(newPeer, canBeHost: false, raiseEvent: false); @@ -264,7 +252,7 @@ namespace StardewModdingAPI.Framework /// <returns>Returns whether the message was handled.</returns> public void OnClientProcessingMessage(IncomingMessage message, Action<OutgoingMessage> sendMessage, Action resume) { - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"CLIENT RECV {(MessageType)message.MessageType} {message.FarmerID}", LogLevel.Trace); switch (message.MessageType) @@ -388,7 +376,7 @@ namespace StardewModdingAPI.Framework string data = JsonConvert.SerializeObject(model, Formatting.None); // log message - if (this.Monitor.IsVerbose) + if (this.LogNetworkTraffic) this.Monitor.Log($"Broadcasting '{messageType}' message: {data}.", LogLevel.Trace); // send message @@ -435,7 +423,7 @@ namespace StardewModdingAPI.Framework private RemoteContextModel ReadContext(BinaryReader reader) { string data = reader.ReadString(); - RemoteContextModel model = this.JsonHelper.Deserialise<RemoteContextModel>(data); + RemoteContextModel model = this.JsonHelper.Deserialize<RemoteContextModel>(data); return model.ApiVersion != null ? model : null; // no data available for unmodded players @@ -447,10 +435,10 @@ namespace StardewModdingAPI.Framework { // parse message string json = message.Reader.ReadString(); - ModMessageModel model = this.JsonHelper.Deserialise<ModMessageModel>(json); + ModMessageModel model = this.JsonHelper.Deserialize<ModMessageModel>(json); HashSet<long> playerIDs = new HashSet<long>(model.ToPlayerIDs ?? this.GetKnownPlayerIDs()); - if (this.Monitor.IsVerbose) - this.Monitor.Log($"Received message: {json}."); + if (this.LogNetworkTraffic) + this.Monitor.Log($"Received message: {json}.", LogLevel.Trace); // notify local mods if (playerIDs.Contains(Game1.player.UniqueMultiplayerID)) @@ -466,7 +454,7 @@ namespace StardewModdingAPI.Framework { newModel.ToPlayerIDs = new[] { peer.PlayerID }; this.Monitor.VerboseLog($" Forwarding message to player {peer.PlayerID}."); - peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialise(newModel, Formatting.None))); + peer.SendMessage(new OutgoingMessage((byte)MessageType.ModMessage, peer.PlayerID, this.JsonHelper.Serialize(newModel, Formatting.None))); } } } @@ -500,7 +488,7 @@ namespace StardewModdingAPI.Framework .ToArray() }; - return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } /// <summary>Get the fields to include in a context sync message sent to other players.</summary> @@ -526,7 +514,7 @@ namespace StardewModdingAPI.Framework .ToArray() }; - return new object[] { this.JsonHelper.Serialise(model, Formatting.None) }; + return new object[] { this.JsonHelper.Serialize(model, Formatting.None) }; } } } diff --git a/src/SMAPI/Framework/Serialisation/ColorConverter.cs b/src/SMAPI/Framework/Serialization/ColorConverter.cs index c27065bf..19979981 100644 --- a/src/SMAPI/Framework/Serialisation/ColorConverter.cs +++ b/src/SMAPI/Framework/Serialization/ColorConverter.cs @@ -1,12 +1,12 @@ using System; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="Color"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="Color"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "B": 76, "G": 51, "R": 25, "A": 102 } /// - Windows format: "26, 51, 76, 102" diff --git a/src/SMAPI/Framework/Serialisation/PointConverter.cs b/src/SMAPI/Framework/Serialization/PointConverter.cs index fbc857d2..8c2f3396 100644 --- a/src/SMAPI/Framework/Serialisation/PointConverter.cs +++ b/src/SMAPI/Framework/Serialization/PointConverter.cs @@ -1,12 +1,12 @@ using System; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="PointConverter"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="PointConverter"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "X": 1, "Y": 2 } /// - Windows format: "1, 2" diff --git a/src/SMAPI/Framework/Serialisation/RectangleConverter.cs b/src/SMAPI/Framework/Serialization/RectangleConverter.cs index 4f55cc32..fbb2e253 100644 --- a/src/SMAPI/Framework/Serialisation/RectangleConverter.cs +++ b/src/SMAPI/Framework/Serialization/RectangleConverter.cs @@ -2,12 +2,12 @@ using System; using System.Text.RegularExpressions; using Microsoft.Xna.Framework; using Newtonsoft.Json.Linq; -using StardewModdingAPI.Toolkit.Serialisation; -using StardewModdingAPI.Toolkit.Serialisation.Converters; +using StardewModdingAPI.Toolkit.Serialization; +using StardewModdingAPI.Toolkit.Serialization.Converters; -namespace StardewModdingAPI.Framework.Serialisation +namespace StardewModdingAPI.Framework.Serialization { - /// <summary>Handles deserialisation of <see cref="Rectangle"/> for crossplatform compatibility.</summary> + /// <summary>Handles deserialization of <see cref="Rectangle"/> for crossplatform compatibility.</summary> /// <remarks> /// - Linux/Mac format: { "X": 1, "Y": 2, "Width": 3, "Height": 4 } /// - Windows format: "{X:1 Y:2 Width:3 Height:4}" diff --git a/src/SMAPI/Framework/SnapshotDiff.cs b/src/SMAPI/Framework/SnapshotDiff.cs new file mode 100644 index 00000000..5b6288ff --- /dev/null +++ b/src/SMAPI/Framework/SnapshotDiff.cs @@ -0,0 +1,43 @@ +using StardewModdingAPI.Framework.StateTracking; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A snapshot of a tracked value.</summary> + /// <typeparam name="T">The tracked value type.</typeparam> + internal class SnapshotDiff<T> + { + /********* + ** Accessors + *********/ + /// <summary>Whether the value changed since the last update.</summary> + public bool IsChanged { get; private set; } + + /// <summary>The previous value.</summary> + public T Old { get; private set; } + + /// <summary>The current value.</summary> + public T New { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Update the snapshot.</summary> + /// <param name="isChanged">Whether the value changed since the last update.</param> + /// <param name="old">The previous value.</param> + /// <param name="now">The current value.</param> + public void Update(bool isChanged, T old, T now) + { + this.IsChanged = isChanged; + this.Old = old; + this.New = now; + } + + /// <summary>Update the snapshot.</summary> + /// <param name="watcher">The value watcher to snapshot.</param> + public void Update(IValueWatcher<T> watcher) + { + this.Update(watcher.IsChanged, watcher.PreviousValue, watcher.CurrentValue); + } + } +} diff --git a/src/SMAPI/Framework/SnapshotListDiff.cs b/src/SMAPI/Framework/SnapshotListDiff.cs new file mode 100644 index 00000000..d4d5df50 --- /dev/null +++ b/src/SMAPI/Framework/SnapshotListDiff.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using StardewModdingAPI.Framework.StateTracking; + +namespace StardewModdingAPI.Framework +{ + /// <summary>A snapshot of a tracked list.</summary> + /// <typeparam name="T">The tracked list value type.</typeparam> + internal class SnapshotListDiff<T> + { + /********* + ** Fields + *********/ + /// <summary>The removed values.</summary> + private readonly List<T> RemovedImpl = new List<T>(); + + /// <summary>The added values.</summary> + private readonly List<T> AddedImpl = new List<T>(); + + + /********* + ** Accessors + *********/ + /// <summary>Whether the value changed since the last update.</summary> + public bool IsChanged { get; private set; } + + /// <summary>The removed values.</summary> + public IEnumerable<T> Removed => this.RemovedImpl; + + /// <summary>The added values.</summary> + public IEnumerable<T> Added => this.AddedImpl; + + + /********* + ** Public methods + *********/ + /// <summary>Update the snapshot.</summary> + /// <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) + { + this.IsChanged = isChanged; + + this.RemovedImpl.Clear(); + this.RemovedImpl.AddRange(removed); + + this.AddedImpl.Clear(); + this.AddedImpl.AddRange(added); + } + + /// <summary>Update the snapshot.</summary> + /// <param name="watcher">The value watcher to snapshot.</param> + public void Update(ICollectionWatcher<T> watcher) + { + this.Update(watcher.IsChanged, watcher.Removed, watcher.Added); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs index 6550f950..32ec8c7e 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ComparableListWatcher.cs @@ -53,7 +53,7 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers { this.AssertNotDisposed(); - // optimise for zero items + // optimize for zero items if (this.CurrentValues.Count == 0) { if (this.LastValues.Count > 0) diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs new file mode 100644 index 00000000..30e6274f --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ImmutableCollectionWatcher.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; + +namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers +{ + /// <summary>A collection watcher which never changes.</summary> + /// <typeparam name="TValue">The value type within the collection.</typeparam> + internal class ImmutableCollectionWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue> + { + /********* + ** Accessors + *********/ + /// <summary>A singleton collection watcher instance.</summary> + public static ImmutableCollectionWatcher<TValue> Instance { get; } = new ImmutableCollectionWatcher<TValue>(); + + /// <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]; + + /// <summary>The values removed since the last reset.</summary> + public IEnumerable<TValue> Removed { get; } = new TValue[0]; + + + /********* + ** Public methods + *********/ + /// <summary>Update the current value if needed.</summary> + public void Update() { } + + /// <summary>Set the current value as the baseline.</summary> + public void Reset() { } + + /// <summary>Stop watching the field and release all references.</summary> + public override void Dispose() { } + } +} diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs index 8301351e..314ff7f5 100644 --- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs +++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs @@ -12,10 +12,13 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /********* ** Public methods *********/ + /**** + ** Values + ****/ /// <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 ComparableWatcher<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>()); } @@ -23,7 +26,7 @@ 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 ComparableWatcher<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>()); } @@ -31,15 +34,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher which detects when an object reference changes.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="getValue">Get the current value.</param> - public static ComparableWatcher<T> ForReference<T>(Func<T> getValue) + public static IValueWatcher<T> ForReference<T>(Func<T> getValue) { return new ComparableWatcher<T>(getValue, new ObjectReferenceComparer<T>()); } + /// <summary>Get a watcher for a net collection.</summary> + /// <typeparam name="T">The value type.</typeparam> + /// <typeparam name="TSelf">The net field instance type.</typeparam> + /// <param name="field">The net collection.</param> + public static IValueWatcher<T> ForNetValue<T, TSelf>(NetFieldBase<T, TSelf> field) where TSelf : NetFieldBase<T, TSelf> + { + return new NetValueWatcher<T, TSelf>(field); + } + + /**** + ** Collections + ****/ /// <summary>Get a watcher which detects when an object reference in a collection changes.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The observable collection.</param> - public static ComparableListWatcher<T> ForReferenceList<T>(ICollection<T> collection) + public static ICollectionWatcher<T> ForReferenceList<T>(ICollection<T> collection) { return new ComparableListWatcher<T>(collection, new ObjectReferenceComparer<T>()); } @@ -47,24 +62,22 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers /// <summary>Get a watcher for an observable collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The observable collection.</param> - public static ObservableCollectionWatcher<T> ForObservableCollection<T>(ObservableCollection<T> collection) + public static ICollectionWatcher<T> ForObservableCollection<T>(ObservableCollection<T> collection) { return new ObservableCollectionWatcher<T>(collection); } - /// <summary>Get a watcher for a net collection.</summary> + /// <summary>Get a watcher for a collection that never changes.</summary> /// <typeparam name="T">The value type.</typeparam> - /// <typeparam name="TSelf">The net field instance type.</typeparam> - /// <param name="field">The net collection.</param> - public static NetValueWatcher<T, TSelf> ForNetValue<T, TSelf>(NetFieldBase<T, TSelf> field) where TSelf : NetFieldBase<T, TSelf> + public static ICollectionWatcher<T> ForImmutableCollection<T>() { - return new NetValueWatcher<T, TSelf>(field); + return ImmutableCollectionWatcher<T>.Instance; } /// <summary>Get a watcher for a net collection.</summary> /// <typeparam name="T">The value type.</typeparam> /// <param name="collection">The net collection.</param> - public static NetCollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> + public static ICollectionWatcher<T> ForNetCollection<T>(NetCollection<T> collection) where T : class, INetObject<INetSerializable> { return new NetCollectionWatcher<T>(collection); } diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs index 2249e41b..1f479e12 100644 --- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs +++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; using Microsoft.Xna.Framework; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; @@ -59,9 +58,7 @@ namespace StardewModdingAPI.Framework.StateTracking this.Location = location; // init watchers - this.BuildingsWatcher = location is BuildableGameLocation buildableLocation - ? WatcherFactory.ForNetCollection(buildableLocation.buildings) - : (ICollectionWatcher<Building>)WatcherFactory.ForObservableCollection(new ObservableCollection<Building>()); + this.BuildingsWatcher = location is BuildableGameLocation buildableLocation ? WatcherFactory.ForNetCollection(buildableLocation.buildings) : WatcherFactory.ForImmutableCollection<Building>(); this.DebrisWatcher = WatcherFactory.ForNetCollection(location.debris); this.LargeTerrainFeaturesWatcher = WatcherFactory.ForNetCollection(location.largeTerrainFeatures); this.NpcsWatcher = WatcherFactory.ForNetCollection(location.characters); diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs index abb4fa24..6302a889 100644 --- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs +++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs @@ -5,7 +5,6 @@ using StardewModdingAPI.Enums; using StardewModdingAPI.Events; using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewValley; -using StardewValley.Locations; using ChangeType = StardewModdingAPI.Events.ChangeType; namespace StardewModdingAPI.Framework.StateTracking @@ -38,9 +37,6 @@ namespace StardewModdingAPI.Framework.StateTracking /// <summary>The player's current location.</summary> public IValueWatcher<GameLocation> LocationWatcher { get; } - /// <summary>The player's current mine level.</summary> - public IValueWatcher<int> MineLevelWatcher { get; } - /// <summary>Tracks changes to the player's skill levels.</summary> public IDictionary<SkillType, IValueWatcher<int>> SkillWatchers { get; } @@ -58,7 +54,6 @@ namespace StardewModdingAPI.Framework.StateTracking // init trackers this.LocationWatcher = WatcherFactory.ForReference(this.GetCurrentLocation); - this.MineLevelWatcher = WatcherFactory.ForEquatable(() => this.LastValidLocation is MineShaft mine ? mine.mineLevel : 0); this.SkillWatchers = new Dictionary<SkillType, IValueWatcher<int>> { [SkillType.Combat] = WatcherFactory.ForNetValue(player.combatLevel), @@ -70,11 +65,7 @@ namespace StardewModdingAPI.Framework.StateTracking }; // track watchers for convenience - this.Watchers.AddRange(new IWatcher[] - { - this.LocationWatcher, - this.MineLevelWatcher - }); + this.Watchers.Add(this.LocationWatcher); this.Watchers.AddRange(this.SkillWatchers.Values); } @@ -124,30 +115,6 @@ namespace StardewModdingAPI.Framework.StateTracking } } - /// <summary>Get the player skill levels which changed.</summary> - public IEnumerable<KeyValuePair<SkillType, IValueWatcher<int>>> GetChangedSkills() - { - return this.SkillWatchers.Where(p => p.Value.IsChanged); - } - - /// <summary>Get the player's new location if it changed.</summary> - /// <param name="location">The player's current location.</param> - /// <returns>Returns whether it changed.</returns> - public bool TryGetNewLocation(out GameLocation location) - { - location = this.LocationWatcher.CurrentValue; - return this.LocationWatcher.IsChanged; - } - - /// <summary>Get the player's new mine level if it changed.</summary> - /// <param name="mineLevel">The player's current mine level.</param> - /// <returns>Returns whether it changed.</returns> - public bool TryGetNewMineLevel(out int mineLevel) - { - mineLevel = this.MineLevelWatcher.CurrentValue; - return this.MineLevelWatcher.IsChanged; - } - /// <summary>Stop watching the player fields and release all references.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs new file mode 100644 index 00000000..d3029540 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Buildings; +using StardewValley.TerrainFeatures; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of a tracked game location.</summary> + internal class LocationSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The tracked location.</summary> + public GameLocation Location { get; } + + /// <summary>Tracks added or removed buildings.</summary> + public SnapshotListDiff<Building> Buildings { get; } = new SnapshotListDiff<Building>(); + + /// <summary>Tracks added or removed debris.</summary> + public SnapshotListDiff<Debris> Debris { get; } = new SnapshotListDiff<Debris>(); + + /// <summary>Tracks added or removed large terrain features.</summary> + public SnapshotListDiff<LargeTerrainFeature> LargeTerrainFeatures { get; } = new SnapshotListDiff<LargeTerrainFeature>(); + + /// <summary>Tracks added or removed NPCs.</summary> + public SnapshotListDiff<NPC> Npcs { get; } = new SnapshotListDiff<NPC>(); + + /// <summary>Tracks added or removed objects.</summary> + public SnapshotListDiff<KeyValuePair<Vector2, Object>> Objects { get; } = new SnapshotListDiff<KeyValuePair<Vector2, Object>>(); + + /// <summary>Tracks added or removed terrain features.</summary> + public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>(); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="location">The tracked location.</param> + public LocationSnapshot(GameLocation location) + { + this.Location = location; + } + + /// <summary>Update the tracked values.</summary> + /// <param name="watcher">The watcher to snapshot.</param> + public void Update(LocationTracker watcher) + { + this.Buildings.Update(watcher.BuildingsWatcher); + this.Debris.Update(watcher.DebrisWatcher); + this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher); + this.Npcs.Update(watcher.NpcsWatcher); + this.Objects.Update(watcher.ObjectsWatcher); + this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs new file mode 100644 index 00000000..7bcd9f82 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Enums; +using StardewModdingAPI.Events; +using StardewValley; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of a tracked player.</summary> + internal class PlayerSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>The player being tracked.</summary> + public Farmer Player { get; } + + /// <summary>The player's current location.</summary> + public SnapshotDiff<GameLocation> Location { get; } = new SnapshotDiff<GameLocation>(); + + /// <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>()); + + /// <summary>Get a list of inventory changes.</summary> + public IEnumerable<ItemStackChange> InventoryChanges { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="player">The player being tracked.</param> + public PlayerSnapshot(Farmer player) + { + this.Player = player; + } + + /// <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.InventoryChanges = watcher.GetInventoryChanges().ToArray(); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs new file mode 100644 index 00000000..cf51e040 --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WatcherSnapshot.cs @@ -0,0 +1,66 @@ +using Microsoft.Xna.Framework; +using StardewValley; +using StardewValley.Menus; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of the game state watchers.</summary> + internal class WatcherSnapshot + { + /********* + ** Accessors + *********/ + /// <summary>Tracks changes to the window size.</summary> + public SnapshotDiff<Point> WindowSize { get; } = new SnapshotDiff<Point>(); + + /// <summary>Tracks changes to the current player.</summary> + 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>(); + + /// <summary>Tracks changes to the save ID.</summary> + public SnapshotDiff<ulong> SaveID { get; } = new SnapshotDiff<ulong>(); + + /// <summary>Tracks changes to the game's locations.</summary> + public WorldLocationsSnapshot Locations { get; } = new WorldLocationsSnapshot(); + + /// <summary>Tracks changes to <see cref="Game1.activeClickableMenu"/>.</summary> + public SnapshotDiff<IClickableMenu> ActiveMenu { get; } = new SnapshotDiff<IClickableMenu>(); + + /// <summary>Tracks changes to the cursor position.</summary> + public SnapshotDiff<ICursorPosition> Cursor { get; } = new SnapshotDiff<ICursorPosition>(); + + /// <summary>Tracks changes to the mouse wheel scroll.</summary> + public SnapshotDiff<int> MouseWheelScroll { get; } = new SnapshotDiff<int>(); + + /// <summary>Tracks changes to the content locale.</summary> + public SnapshotDiff<LocalizedContentManager.LanguageCode> Locale { get; } = new SnapshotDiff<LocalizedContentManager.LanguageCode>(); + + + /********* + ** Public methods + *********/ + /// <summary>Update the tracked values.</summary> + /// <param name="watchers">The watchers to snapshot.</param> + public void Update(WatcherCore watchers) + { + // update player instance + if (watchers.CurrentPlayerTracker == null) + this.CurrentPlayer = null; + else if (watchers.CurrentPlayerTracker.Player != this.CurrentPlayer?.Player) + this.CurrentPlayer = new PlayerSnapshot(watchers.CurrentPlayerTracker.Player); + + // update snapshots + this.WindowSize.Update(watchers.WindowSizeWatcher); + this.Locale.Update(watchers.LocaleWatcher); + this.CurrentPlayer?.Update(watchers.CurrentPlayerTracker); + this.Time.Update(watchers.TimeWatcher); + this.SaveID.Update(watchers.SaveIdWatcher); + this.Locations.Update(watchers.LocationsWatcher); + this.ActiveMenu.Update(watchers.ActiveMenuWatcher); + this.Cursor.Update(watchers.CursorWatcher); + this.MouseWheelScroll.Update(watchers.MouseWheelScrollWatcher); + } + } +} diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs new file mode 100644 index 00000000..73ed2d8f --- /dev/null +++ b/src/SMAPI/Framework/StateTracking/Snapshots/WorldLocationsSnapshot.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using StardewModdingAPI.Framework.StateTracking.Comparers; +using StardewValley; + +namespace StardewModdingAPI.Framework.StateTracking.Snapshots +{ + /// <summary>A frozen snapshot of the tracked game locations.</summary> + internal class WorldLocationsSnapshot + { + /********* + ** Fields + *********/ + /// <summary>A map of tracked locations.</summary> + private readonly Dictionary<GameLocation, LocationSnapshot> LocationsDict = new Dictionary<GameLocation, LocationSnapshot>(new ObjectReferenceComparer<GameLocation>()); + + + /********* + ** Accessors + *********/ + /// <summary>Tracks changes to the location list.</summary> + public SnapshotListDiff<GameLocation> LocationList { get; } = new SnapshotListDiff<GameLocation>(); + + /// <summary>The tracked locations.</summary> + public IEnumerable<LocationSnapshot> Locations => this.LocationsDict.Values; + + + /********* + ** Public methods + *********/ + /// <summary>Update the tracked values.</summary> + /// <param name="watcher">The watcher to snapshot.</param> + public void Update(WorldLocationsTracker watcher) + { + // update location list + this.LocationList.Update(watcher.IsLocationListChanged, watcher.Added, watcher.Removed); + + // remove missing locations + foreach (var key in this.LocationsDict.Keys.Where(key => !watcher.HasLocationTracker(key)).ToArray()) + this.LocationsDict.Remove(key); + + // update locations + foreach (LocationTracker locationWatcher in watcher.Locations) + { + 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 f09c69c1..303a4f3a 100644 --- a/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs +++ b/src/SMAPI/Framework/StateTracking/WorldLocationsTracker.cs @@ -117,6 +117,13 @@ namespace StardewModdingAPI.Framework.StateTracking watcher.Reset(); } + /// <summary>Get whether the given location is tracked.</summary> + /// <param name="location">The location to check.</param> + public bool HasLocationTracker(GameLocation location) + { + return this.LocationDict.ContainsKey(location); + } + /// <summary>Stop watching the player fields and release all references.</summary> public void Dispose() { diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs new file mode 100644 index 00000000..f2738633 --- /dev/null +++ b/src/SMAPI/Framework/Translator.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Encapsulates access to arbitrary translations. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class Translator + { + /********* + ** Fields + *********/ + /// <summary>The translations for each locale.</summary> + private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> + private IDictionary<string, Translation> ForLocale; + + + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + public string Locale { get; private set; } + + /// <summary>The game's current language code.</summary> + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public Translator() + { + this.SetLocale(string.Empty, LocalizedContentManager.LanguageCode.en); + } + + /// <summary>Set the current locale and precache translations.</summary> + /// <param name="locale">The current locale.</param> + /// <param name="localeEnum">The game's current language code.</param> + public void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary<string, Translation>(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair.Key, new Translation(this.Locale, pair.Key, pair.Value)); + } + } + } + + /// <summary>Get all translations for the current locale.</summary> + public IEnumerable<Translation> GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out Translation translation); + return translation ?? new Translation(this.Locale, key, null); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + /// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param> + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// <summary>Set the translations to use.</summary> + /// <param name="translations">The translations to use.</param> + internal Translator SetTranslations(IDictionary<string, IDictionary<string, string>> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> + /// <param name="locale">The locale for which to find valid locales.</param> + private IEnumerable<string> GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} diff --git a/src/SMAPI/GamePlatform.cs b/src/SMAPI/GamePlatform.cs index 3bd74462..b64595e4 100644 --- a/src/SMAPI/GamePlatform.cs +++ b/src/SMAPI/GamePlatform.cs @@ -1,10 +1,13 @@ -using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; namespace StardewModdingAPI { /// <summary>The game's platform version.</summary> public enum GamePlatform { + /// <summary>The Android version of the game.</summary> + Android = Platform.Android, + /// <summary>The Linux version of the game.</summary> Linux = Platform.Linux, diff --git a/src/SMAPI/IAssetDataForDictionary.cs b/src/SMAPI/IAssetDataForDictionary.cs index 911599d9..1136316f 100644 --- a/src/SMAPI/IAssetDataForDictionary.cs +++ b/src/SMAPI/IAssetDataForDictionary.cs @@ -1,32 +1,7 @@ -using System; using System.Collections.Generic; -using StardewModdingAPI.Framework.Content; namespace StardewModdingAPI { /// <summary>Encapsulates access and changes to dictionary content being read from a data file.</summary> - public interface IAssetDataForDictionary<TKey, TValue> : IAssetData<IDictionary<TKey, TValue>> - { -#if !SMAPI_3_0_STRICT - /********* - ** Public methods - *********/ - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">The entry value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - void Set(TKey key, TValue value); - - /// <summary>Add or replace an entry in the dictionary.</summary> - /// <param name="key">The entry key.</param> - /// <param name="value">A callback which accepts the current value and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - void Set(TKey key, Func<TValue, TValue> value); - - /// <summary>Dynamically replace values in the dictionary.</summary> - /// <param name="replacer">A lambda which takes the current key and value for an entry, and returns the new value.</param> - [Obsolete("Access " + nameof(AssetData<IDictionary<TKey, TValue>>.Data) + "field directly.")] - void Set(Func<TKey, TValue, TValue> replacer); -#endif - } + public interface IAssetDataForDictionary<TKey, TValue> : IAssetData<IDictionary<TKey, TValue>> { } } diff --git a/src/SMAPI/IAssetInfo.cs b/src/SMAPI/IAssetInfo.cs index 5dd58e2e..6cdf01ee 100644 --- a/src/SMAPI/IAssetInfo.cs +++ b/src/SMAPI/IAssetInfo.cs @@ -8,10 +8,10 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>The content's locale code, if the content is localised.</summary> + /// <summary>The content's locale code, if the content is localized.</summary> string Locale { get; } - /// <summary>The normalised asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> + /// <summary>The normalized asset name being read. The format may change between platforms; see <see cref="AssetNameEquals"/> to compare with a known path.</summary> string AssetName { get; } /// <summary>The content data type.</summary> @@ -21,7 +21,7 @@ namespace StardewModdingAPI /********* ** Public methods *********/ - /// <summary>Get whether the asset name being loaded matches a given name after normalisation.</summary> + /// <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> bool AssetNameEquals(string path); } diff --git a/src/SMAPI/IContentHelper.cs b/src/SMAPI/IContentHelper.cs index 1b87183d..dd7eb758 100644 --- a/src/SMAPI/IContentHelper.cs +++ b/src/SMAPI/IContentHelper.cs @@ -38,10 +38,10 @@ namespace StardewModdingAPI /// <exception cref="ContentLoadException">The content asset couldn't be loaded (e.g. because it doesn't exist).</exception> T Load<T>(string key, ContentSource source = ContentSource.ModFolder); - /// <summary>Normalise 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> + /// <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> [Pure] - string NormaliseAssetName(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> diff --git a/src/SMAPI/IContentPack.cs b/src/SMAPI/IContentPack.cs index 9ba32394..c0479eae 100644 --- a/src/SMAPI/IContentPack.cs +++ b/src/SMAPI/IContentPack.cs @@ -17,14 +17,21 @@ namespace StardewModdingAPI /// <summary>The content pack's manifest.</summary> IManifest Manifest { get; } + /// <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; } + /********* ** Public methods *********/ + /// <summary>Get whether a given file exists in the content pack.</summary> + /// <param name="path">The file path to check.</param> + bool HasFile(string path); + /// <summary>Read a JSON file from the content pack 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 content pack directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <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; diff --git a/src/SMAPI/IContentPackHelper.cs b/src/SMAPI/IContentPackHelper.cs index e4949f58..c48a4f86 100644 --- a/src/SMAPI/IContentPackHelper.cs +++ b/src/SMAPI/IContentPackHelper.cs @@ -11,7 +11,7 @@ namespace StardewModdingAPI /// <summary>Get all content packs loaded for this mod.</summary> IEnumerable<IContentPack> GetOwned(); - /// <summary>Create a temporary content pack to read files from a directory, using randomised manifest fields. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> + /// <summary>Create a temporary content pack to read files from a directory, using randomized manifest fields. Temporary content packs will not appear in the SMAPI log and update checks will not be performed.</summary> /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> IContentPack CreateFake(string directoryPath); diff --git a/src/SMAPI/IDataHelper.cs b/src/SMAPI/IDataHelper.cs index 6afdc529..252030bd 100644 --- a/src/SMAPI/IDataHelper.cs +++ b/src/SMAPI/IDataHelper.cs @@ -14,7 +14,7 @@ namespace StardewModdingAPI /// <summary>Read data from a JSON file in the mod's folder.</summary> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <param name="path">The file path relative to the mod folder.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> + /// <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; diff --git a/src/SMAPI/IModHelper.cs b/src/SMAPI/IModHelper.cs index 0220b4f7..cd746e06 100644 --- a/src/SMAPI/IModHelper.cs +++ b/src/SMAPI/IModHelper.cs @@ -1,5 +1,3 @@ -using System; -using System.Collections.Generic; using StardewModdingAPI.Events; namespace StardewModdingAPI @@ -58,41 +56,5 @@ namespace StardewModdingAPI /// <typeparam name="TConfig">The config class type.</typeparam> /// <param name="config">The config settings to save.</param> void WriteConfig<TConfig>(TConfig config) where TConfig : class, new(); - -#if !SMAPI_3_0_STRICT - /**** - ** Generic JSON files - ****/ - /// <summary>Read a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <returns>Returns the deserialised model, or <c>null</c> if the file doesn't exist or is empty.</returns> - [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.ReadJsonFile) + " instead")] - TModel ReadJsonFile<TModel>(string path) where TModel : class; - - /// <summary>Save to a JSON file.</summary> - /// <typeparam name="TModel">The model type.</typeparam> - /// <param name="path">The file path relative to the mod directory.</param> - /// <param name="model">The model to save.</param> - [Obsolete("Use " + nameof(IModHelper.Data) + "." + nameof(IDataHelper.WriteJsonFile) + " instead")] - void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; - - /**** - ** Content packs - ****/ - /// <summary>Manually create a transitional content pack to support pre-SMAPI content packs. This provides a way to access legacy content packs using the SMAPI content pack APIs, but the content pack will not be visible in the log or validated by SMAPI.</summary> - /// <param name="directoryPath">The absolute directory path containing the content pack files.</param> - /// <param name="id">The content pack's unique ID.</param> - /// <param name="name">The content pack name.</param> - /// <param name="description">The content pack description.</param> - /// <param name="author">The content pack author's name.</param> - /// <param name="version">The content pack version.</param> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.CreateTemporary) + " instead")] - IContentPack CreateTransitionalContentPack(string directoryPath, string id, string name, string description, string author, ISemanticVersion version); - - /// <summary>Get all content packs loaded for this mod.</summary> - [Obsolete("Use " + nameof(IModHelper) + "." + nameof(IModHelper.ContentPacks) + "." + nameof(IContentPackHelper.GetOwned) + " instead")] - IEnumerable<IContentPack> GetContentPacks(); -#endif } } diff --git a/src/SMAPI/IMonitor.cs b/src/SMAPI/IMonitor.cs index 943c1c59..f2d110b8 100644 --- a/src/SMAPI/IMonitor.cs +++ b/src/SMAPI/IMonitor.cs @@ -6,9 +6,6 @@ namespace StardewModdingAPI /********* ** Accessors *********/ - /// <summary>Whether SMAPI is aborting. Mods don't need to worry about this unless they have background tasks.</summary> - bool IsExiting { get; } - /// <summary>Whether verbose logging is enabled. This enables more detailed diagnostic messages than are normally needed.</summary> bool IsVerbose { get; } @@ -24,9 +21,5 @@ namespace StardewModdingAPI /// <summary>Log a message that only appears when <see cref="IsVerbose"/> is enabled.</summary> /// <param name="message">The message to log.</param> void VerboseLog(string message); - - /// <summary>Immediately exit the game without saving. This should only be invoked when an irrecoverable fatal error happens that risks save corruption or game-breaking bugs.</summary> - /// <param name="reason">The reason for the shutdown.</param> - void ExitGameImmediately(string reason); } } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index a64dc89b..1c0a04f0 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -2,13 +2,13 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Reflection; using Microsoft.Xna.Framework.Graphics; using StardewModdingAPI.Framework.Reflection; using StardewValley; using StardewValley.BellsAndWhistles; using StardewValley.Buildings; using StardewValley.Characters; +using StardewValley.GameData.Movies; using StardewValley.Locations; using StardewValley.Menus; using StardewValley.Objects; @@ -25,8 +25,8 @@ namespace StardewModdingAPI.Metadata /********* ** Fields *********/ - /// <summary>Normalises an asset key to match the cache key.</summary> - private readonly Func<string, string> GetNormalisedPath; + /// <summary>Normalizes an asset key to match the cache key and assert that it's valid.</summary> + private readonly Func<string, string> AssertAndNormalizeAssetName; /// <summary>Simplifies access to private game code.</summary> private readonly Reflector Reflection; @@ -34,32 +34,72 @@ namespace StardewModdingAPI.Metadata /// <summary>Encapsulates monitoring and logging.</summary> private readonly IMonitor Monitor; + /// <summary>Optimized bucket categories for batch reloading assets.</summary> + private enum AssetBucket + { + /// <summary>NPC overworld sprites.</summary> + Sprite, + + /// <summary>Villager dialogue portraits.</summary> + Portrait, + + /// <summary>Any other asset.</summary> + Other + }; + /********* ** Public methods *********/ - /// <summary>Initialise the core asset data.</summary> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <summary>Initialize the core asset data.</summary> + /// <param name="assertAndNormalizeAssetName">Normalizes an asset key to match the cache key and assert that it's valid.</param> /// <param name="reflection">Simplifies access to private code.</param> /// <param name="monitor">Encapsulates monitoring and logging.</param> - public CoreAssetPropagator(Func<string, string> getNormalisedPath, Reflector reflection, IMonitor monitor) + public CoreAssetPropagator(Func<string, string> assertAndNormalizeAssetName, Reflector reflection, IMonitor monitor) { - this.GetNormalisedPath = getNormalisedPath; + this.AssertAndNormalizeAssetName = assertAndNormalizeAssetName; this.Reflection = reflection; this.Monitor = monitor; } /// <summary>Reload one of the game's core assets (if applicable).</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> - /// <param name="type">The asset type to reload.</param> - /// <returns>Returns whether an asset was reloaded.</returns> - public bool Propagate(LocalizedContentManager content, string key, Type type) + /// <param name="assets">The asset keys and types to reload.</param> + /// <returns>Returns the number of reloaded assets.</returns> + public int Propagate(LocalizedContentManager content, IDictionary<string, Type> assets) { - object result = this.PropagateImpl(content, key, type); - if (result is bool b) - return b; - return result != null; + // group into optimized lists + var buckets = assets.GroupBy(p => + { + if (this.IsInFolder(p.Key, "Characters") || this.IsInFolder(p.Key, "Characters\\Monsters")) + return AssetBucket.Sprite; + + if (this.IsInFolder(p.Key, "Portraits")) + return AssetBucket.Portrait; + + return AssetBucket.Other; + }); + + // reload assets + int reloaded = 0; + foreach (var bucket in buckets) + { + switch (bucket.Key) + { + case AssetBucket.Sprite: + reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key)); + break; + + case AssetBucket.Portrait: + reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key)); + break; + + default: + reloaded += bucket.Count(p => this.PropagateOther(content, p.Key, p.Value)); + break; + } + } + return reloaded; } @@ -71,9 +111,9 @@ namespace StardewModdingAPI.Metadata /// <param name="key">The asset key to reload.</param> /// <param name="type">The asset type to reload.</param> /// <returns>Returns whether an asset was loaded. The return value may be true or false, or a non-null value for true.</returns> - private object PropagateImpl(LocalizedContentManager content, string key, Type type) + private bool PropagateOther(LocalizedContentManager content, string key, Type type) { - key = this.GetNormalisedPath(key); + key = this.AssertAndNormalizeAssetName(key); /**** ** Special case: current map tilesheet @@ -84,7 +124,7 @@ namespace StardewModdingAPI.Metadata { foreach (TileSheet tilesheet in Game1.currentLocation.map.TileSheets) { - if (this.GetNormalisedPath(tilesheet.ImageSource) == key) + if (this.NormalizeAssetNameIgnoringEmpty(tilesheet.ImageSource) == key) Game1.mapDisplayDevice.LoadTileSheet(tilesheet); } } @@ -97,22 +137,21 @@ namespace StardewModdingAPI.Metadata bool anyChanged = false; foreach (GameLocation location in this.GetLocations()) { - if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.GetNormalisedPath(location.mapPath.Value) == key) + if (!string.IsNullOrWhiteSpace(location.mapPath.Value) && this.NormalizeAssetNameIgnoringEmpty(location.mapPath.Value) == key) { - // reload map data - this.Reflection.GetMethod(location, "reloadMap").Invoke(); - this.Reflection.GetMethod(location, "updateWarps").Invoke(); + // general updates + location.reloadMap(); + location.updateSeasonalTileSheets(); + location.updateWarps(); - // reload doors - { - Type interiorDoorDictType = Type.GetType($"StardewValley.InteriorDoorDictionary, {Constants.GameAssemblyName}", throwOnError: true); - ConstructorInfo constructor = interiorDoorDictType.GetConstructor(new[] { typeof(GameLocation) }); - if (constructor == null) - throw new InvalidOperationException("Can't reset location doors: constructor not found for InteriorDoorDictionary type."); - object instance = constructor.Invoke(new object[] { location }); + // update interior doors + location.interiorDoors.Clear(); + foreach (var entry in new InteriorDoorDictionary(location)) + location.interiorDoors.Add(entry); - this.Reflection.GetField<object>(location, "interiorDoors").SetValue(instance); - } + // update doors + location.doors.Clear(); + location.updateDoors(); anyChanged = true; } @@ -124,15 +163,11 @@ namespace StardewModdingAPI.Metadata ** Propagate by key ****/ Reflector reflection = this.Reflection; - switch (key.ToLower().Replace("/", "\\")) // normalised key so we can compare statically + switch (key.ToLower().Replace("/", "\\")) // normalized key so we can compare statically { /**** ** Animals ****/ - case "animals\\cat": - return this.ReloadPetOrHorseSprites<Cat>(content, key); - case "animals\\dog": - return this.ReloadPetOrHorseSprites<Dog>(content, key); case "animals\\horse": return this.ReloadPetOrHorseSprites<Horse>(content, key); @@ -146,204 +181,314 @@ namespace StardewModdingAPI.Metadata /**** ** Content\Characters\Farmer ****/ - case "characters\\farmer\\accessories": // Game1.loadContent - return FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key); + case "characters\\farmer\\accessories": // Game1.LoadContent + FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key); + return true; case "characters\\farmer\\farmer_base": // Farmer + case "characters\\farmer\\farmer_base_bald": if (Game1.player == null || !Game1.player.IsMale) return false; - return Game1.player.FarmerRenderer = new FarmerRenderer(key); + Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); + return true; case "characters\\farmer\\farmer_girl_base": // Farmer + case "characters\\farmer\\farmer_girl_bald": if (Game1.player == null || Game1.player.IsMale) return false; - return Game1.player.FarmerRenderer = new FarmerRenderer(key); + Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); + return true; - case "characters\\farmer\\hairstyles": // Game1.loadContent - return FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key); + case "characters\\farmer\\hairstyles": // Game1.LoadContent + FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key); + return true; - case "characters\\farmer\\hats": // Game1.loadContent - return FarmerRenderer.hatsTexture = content.Load<Texture2D>(key); + case "characters\\farmer\\hats": // Game1.LoadContent + FarmerRenderer.hatsTexture = content.Load<Texture2D>(key); + return true; - case "characters\\farmer\\shirts": // Game1.loadContent - return FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key); + case "characters\\farmer\\pants": // Game1.LoadContent + FarmerRenderer.pantsTexture = content.Load<Texture2D>(key); + return true; + + case "characters\\farmer\\shirts": // Game1.LoadContent + FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key); + return true; /**** ** Content\Data ****/ - case "data\\achievements": // Game1.loadContent - return Game1.achievements = content.Load<Dictionary<int, string>>(key); + case "data\\achievements": // Game1.LoadContent + Game1.achievements = content.Load<Dictionary<int, string>>(key); + return true; - case "data\\bigcraftablesinformation": // Game1.loadContent - return Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key); + case "data\\bigcraftablesinformation": // Game1.LoadContent + Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key); + return true; + + case "data\\clothinginformation": // Game1.LoadContent + Game1.clothingInformation = content.Load<Dictionary<int, string>>(key); + return true; + + 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 - return CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key); + CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key); + return true; case "data\\craftingrecipes": // CraftingRecipe.InitShared - return CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key); + CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key); + return true; + + case "data\\farmanimals": // FarmAnimal constructor + return this.ReloadFarmAnimalData(); + + case "data\\moviereactions": // MovieTheater.GetMovieReactions + this.Reflection + .GetField<List<MovieCharacterReaction>>(typeof(MovieTheater), "_genericReactions") + .SetValue(content.Load<List<MovieCharacterReaction>>(key)); + return true; + + case "data\\movies": // MovieTheater.GetMovieData + this.Reflection + .GetField<Dictionary<string, MovieData>>(typeof(MovieTheater), "_movieData") + .SetValue(content.Load<Dictionary<string, MovieData>>(key)); + return true; case "data\\npcdispositions": // NPC constructor return this.ReloadNpcDispositions(content, key); - case "data\\npcgifttastes": // Game1.loadContent - return Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); + case "data\\npcgifttastes": // Game1.LoadContent + Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); + return true; - case "data\\objectinformation": // Game1.loadContent - return Game1.objectInformation = content.Load<Dictionary<int, string>>(key); + case "data\\objectcontexttags": // Game1.LoadContent + Game1.objectContextTags = content.Load<Dictionary<string, string>>(key); + return true; + + case "data\\objectinformation": // Game1.LoadContent + Game1.objectInformation = content.Load<Dictionary<int, string>>(key); + return true; /**** ** Content\Fonts ****/ - case "fonts\\spritefont1": // Game1.loadContent - return Game1.dialogueFont = content.Load<SpriteFont>(key); + case "fonts\\spritefont1": // Game1.LoadContent + Game1.dialogueFont = content.Load<SpriteFont>(key); + return true; - case "fonts\\smallfont": // Game1.loadContent - return Game1.smallFont = content.Load<SpriteFont>(key); + case "fonts\\smallfont": // Game1.LoadContent + Game1.smallFont = content.Load<SpriteFont>(key); + return true; - case "fonts\\tinyfont": // Game1.loadContent - return Game1.tinyFont = content.Load<SpriteFont>(key); + case "fonts\\tinyfont": // Game1.LoadContent + Game1.tinyFont = content.Load<SpriteFont>(key); + return true; - case "fonts\\tinyfontborder": // Game1.loadContent - return Game1.tinyFontBorder = content.Load<SpriteFont>(key); + case "fonts\\tinyfontborder": // Game1.LoadContent + Game1.tinyFontBorder = content.Load<SpriteFont>(key); + return true; /**** - ** Content\Lighting + ** Content\LooseSprites\Lighting ****/ - case "loosesprites\\lighting\\greenlight": // Game1.loadContent - return Game1.cauldronLight = content.Load<Texture2D>(key); + case "loosesprites\\lighting\\greenlight": // Game1.LoadContent + Game1.cauldronLight = content.Load<Texture2D>(key); + return true; - case "loosesprites\\lighting\\indoorwindowlight": // Game1.loadContent - return Game1.indoorWindowLight = content.Load<Texture2D>(key); + case "loosesprites\\lighting\\indoorwindowlight": // Game1.LoadContent + Game1.indoorWindowLight = content.Load<Texture2D>(key); + return true; - case "loosesprites\\lighting\\lantern": // Game1.loadContent - return Game1.lantern = content.Load<Texture2D>(key); + case "loosesprites\\lighting\\lantern": // Game1.LoadContent + Game1.lantern = content.Load<Texture2D>(key); + return true; - case "loosesprites\\lighting\\sconcelight": // Game1.loadContent - return Game1.sconceLight = content.Load<Texture2D>(key); + case "loosesprites\\lighting\\sconcelight": // Game1.LoadContent + Game1.sconceLight = content.Load<Texture2D>(key); + return true; - case "loosesprites\\lighting\\windowlight": // Game1.loadContent - return Game1.windowLight = content.Load<Texture2D>(key); + case "loosesprites\\lighting\\windowlight": // Game1.LoadContent + Game1.windowLight = content.Load<Texture2D>(key); + return true; /**** ** Content\LooseSprites ****/ - case "loosesprites\\controllermaps": // Game1.loadContent - return Game1.controllerMaps = content.Load<Texture2D>(key); + case "loosesprites\\birds": // Game1.LoadContent + Game1.birdsSpriteSheet = content.Load<Texture2D>(key); + return true; - case "loosesprites\\cursors": // Game1.loadContent - return Game1.mouseCursors = content.Load<Texture2D>(key); + case "loosesprites\\concessions": // Game1.LoadContent + Game1.concessionsSpriteSheet = content.Load<Texture2D>(key); + return true; - case "loosesprites\\daybg": // Game1.loadContent - return Game1.daybg = content.Load<Texture2D>(key); + case "loosesprites\\controllermaps": // Game1.LoadContent + Game1.controllerMaps = content.Load<Texture2D>(key); + return true; - case "loosesprites\\font_bold": // Game1.loadContent - return SpriteText.spriteTexture = content.Load<Texture2D>(key); + case "loosesprites\\cursors": // Game1.LoadContent + Game1.mouseCursors = content.Load<Texture2D>(key); + foreach (DayTimeMoneyBox menu in Game1.onScreenMenus.OfType<DayTimeMoneyBox>()) + { + foreach (ClickableTextureComponent button in new[] { menu.questButton, menu.zoomInButton, menu.zoomOutButton }) + button.texture = Game1.mouseCursors; + } + return true; - case "loosesprites\\font_colored": // Game1.loadContent - return SpriteText.coloredTexture = content.Load<Texture2D>(key); + case "loosesprites\\cursors2": // Game1.LoadContent + Game1.mouseCursors2 = content.Load<Texture2D>(key); + return true; - case "loosesprites\\nightbg": // Game1.loadContent - return Game1.nightbg = content.Load<Texture2D>(key); + case "loosesprites\\daybg": // Game1.LoadContent + Game1.daybg = content.Load<Texture2D>(key); + return true; + + case "loosesprites\\font_bold": // Game1.LoadContent + SpriteText.spriteTexture = content.Load<Texture2D>(key); + return true; - case "loosesprites\\shadow": // Game1.loadContent - return Game1.shadowTexture = content.Load<Texture2D>(key); + case "loosesprites\\font_colored": // Game1.LoadContent + SpriteText.coloredTexture = content.Load<Texture2D>(key); + return true; + + case "loosesprites\\nightbg": // Game1.LoadContent + Game1.nightbg = content.Load<Texture2D>(key); + return true; + + case "loosesprites\\shadow": // Game1.LoadContent + Game1.shadowTexture = content.Load<Texture2D>(key); + return true; /**** - ** Content\Critters + ** Content\TileSheets ****/ - case "tilesheets\\crops": // Game1.loadContent - return Game1.cropSpriteSheet = content.Load<Texture2D>(key); + case "tilesheets\\critters": // Critter constructor + this.ReloadCritterTextures(content, key); + return true; - case "tilesheets\\debris": // Game1.loadContent - return Game1.debrisSpriteSheet = content.Load<Texture2D>(key); + case "tilesheets\\crops": // Game1.LoadContent + Game1.cropSpriteSheet = content.Load<Texture2D>(key); + return true; - case "tilesheets\\emotes": // Game1.loadContent - return Game1.emoteSpriteSheet = content.Load<Texture2D>(key); + case "tilesheets\\debris": // Game1.LoadContent + Game1.debrisSpriteSheet = content.Load<Texture2D>(key); + return true; - case "tilesheets\\furniture": // Game1.loadContent - return Furniture.furnitureTexture = content.Load<Texture2D>(key); + case "tilesheets\\emotes": // Game1.LoadContent + Game1.emoteSpriteSheet = content.Load<Texture2D>(key); + return true; - case "tilesheets\\projectiles": // Game1.loadContent - return Projectile.projectileSheet = content.Load<Texture2D>(key); + case "tilesheets\\furniture": // Game1.LoadContent + Furniture.furnitureTexture = content.Load<Texture2D>(key); + return true; - case "tilesheets\\rain": // Game1.loadContent - return Game1.rainTexture = content.Load<Texture2D>(key); + case "tilesheets\\projectiles": // Game1.LoadContent + Projectile.projectileSheet = content.Load<Texture2D>(key); + return true; + + case "tilesheets\\rain": // Game1.LoadContent + Game1.rainTexture = content.Load<Texture2D>(key); + return true; case "tilesheets\\tools": // Game1.ResetToolSpriteSheet Game1.ResetToolSpriteSheet(); return true; - case "tilesheets\\weapons": // Game1.loadContent - return Tool.weaponsTexture = content.Load<Texture2D>(key); + case "tilesheets\\weapons": // Game1.LoadContent + Tool.weaponsTexture = content.Load<Texture2D>(key); + return true; /**** ** Content\Maps ****/ - case "maps\\menutiles": // Game1.loadContent - return Game1.menuTexture = content.Load<Texture2D>(key); + case "maps\\menutiles": // Game1.LoadContent + Game1.menuTexture = content.Load<Texture2D>(key); + return true; + + case "maps\\menutilesuncolored": // Game1.LoadContent + Game1.uncoloredMenuTexture = content.Load<Texture2D>(key); + return true; - case "maps\\springobjects": // Game1.loadContent - return Game1.objectSpriteSheet = content.Load<Texture2D>(key); + case "maps\\springobjects": // Game1.LoadContent + Game1.objectSpriteSheet = content.Load<Texture2D>(key); + return true; case "maps\\walls_and_floors": // Wallpaper - return Wallpaper.wallpaperTexture = content.Load<Texture2D>(key); + Wallpaper.wallpaperTexture = content.Load<Texture2D>(key); + return true; /**** ** Content\Minigames ****/ case "minigames\\clouds": // TitleMenu - if (Game1.activeClickableMenu is TitleMenu) { - reflection.GetField<Texture2D>(Game1.activeClickableMenu, "cloudsTexture").SetValue(content.Load<Texture2D>(key)); - return true; + if (Game1.activeClickableMenu is TitleMenu titleMenu) + { + titleMenu.cloudsTexture = content.Load<Texture2D>(key); + return true; + } } return false; case "minigames\\titlebuttons": // TitleMenu - if (Game1.activeClickableMenu is TitleMenu titleMenu) { - Texture2D texture = content.Load<Texture2D>(key); - reflection.GetField<Texture2D>(titleMenu, "titleButtonsTexture").SetValue(texture); - foreach (TemporaryAnimatedSprite bird in reflection.GetField<List<TemporaryAnimatedSprite>>(titleMenu, "birds").GetValue()) - bird.texture = texture; - return true; + if (Game1.activeClickableMenu is TitleMenu titleMenu) + { + Texture2D texture = content.Load<Texture2D>(key); + titleMenu.titleButtonsTexture = texture; + foreach (TemporaryAnimatedSprite bird in titleMenu.birds) + bird.texture = texture; + return true; + } } return false; /**** ** Content\TileSheets ****/ - case "tilesheets\\animations": // Game1.loadContent - return Game1.animations = content.Load<Texture2D>(key); + case "tilesheets\\animations": // Game1.LoadContent + Game1.animations = content.Load<Texture2D>(key); + return true; - case "tilesheets\\buffsicons": // Game1.loadContent - return Game1.buffsIcons = content.Load<Texture2D>(key); + case "tilesheets\\buffsicons": // Game1.LoadContent + Game1.buffsIcons = content.Load<Texture2D>(key); + return true; case "tilesheets\\bushes": // new Bush() - reflection.GetField<Lazy<Texture2D>>(typeof(Bush), "texture").SetValue(new Lazy<Texture2D>(() => content.Load<Texture2D>(key))); + Bush.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); return true; - case "tilesheets\\craftables": // Game1.loadContent - return Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); + case "tilesheets\\craftables": // Game1.LoadContent + Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); + return true; case "tilesheets\\fruittrees": // FruitTree - return FruitTree.texture = content.Load<Texture2D>(key); + FruitTree.texture = content.Load<Texture2D>(key); + return true; /**** ** Content\TerrainFeatures ****/ case "terrainfeatures\\flooring": // Flooring - return Flooring.floorsTexture = content.Load<Texture2D>(key); + Flooring.floorsTexture = content.Load<Texture2D>(key); + return true; case "terrainfeatures\\hoedirt": // from HoeDirt - return HoeDirt.lightTexture = content.Load<Texture2D>(key); + HoeDirt.lightTexture = content.Load<Texture2D>(key); + return true; case "terrainfeatures\\hoedirtdark": // from HoeDirt - return HoeDirt.darkTexture = content.Load<Texture2D>(key); + HoeDirt.darkTexture = content.Load<Texture2D>(key); + return true; case "terrainfeatures\\hoedirtsnow": // from HoeDirt - return HoeDirt.snowTexture = content.Load<Texture2D>(key); + HoeDirt.snowTexture = content.Load<Texture2D>(key); + return true; case "terrainfeatures\\mushroom_tree": // from Tree return this.ReloadTreeTextures(content, key, Tree.mushroomTree); @@ -370,21 +515,19 @@ namespace StardewModdingAPI.Metadata } // 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 (this.IsInFolder(key, "Buildings")) return this.ReloadBuildings(content, key); - if (this.IsInFolder(key, "Characters") || this.IsInFolder(key, "Characters\\Monsters")) - return this.ReloadNpcSprites(content, key); - if (this.KeyStartsWith(key, "LooseSprites\\Fence")) return this.ReloadFenceTextures(key); - if (this.IsInFolder(key, "Portraits")) - return this.ReloadNpcPortraits(content, key); - // dynamic data if (this.IsInFolder(key, "Characters\\Dialogue")) return this.ReloadNpcDialogue(key); @@ -411,7 +554,10 @@ namespace StardewModdingAPI.Metadata where TAnimal : NPC { // find matches - TAnimal[] animals = this.GetCharacters().OfType<TAnimal>().ToArray(); + TAnimal[] animals = this.GetCharacters() + .OfType<TAnimal>() + .Where(p => key == this.NormalizeAssetNameIgnoringEmpty(p.Sprite?.Texture?.Name)) + .ToArray(); if (!animals.Any()) return false; @@ -478,6 +624,49 @@ namespace StardewModdingAPI.Metadata return false; } + /// <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> + /// <returns>Returns the number of reloaded assets.</returns> + private int ReloadCritterTextures(LocalizedContentManager content, string key) + { + // get critters + Critter[] critters = + ( + from location in this.GetLocations() + let locCritters = this.Reflection.GetField<List<Critter>>(location, "critters").GetValue() + where locCritters != null + from Critter critter in locCritters + where this.NormalizeAssetNameIgnoringEmpty(critter.sprite?.Texture?.Name) == key + select critter + ) + .ToArray(); + if (!critters.Any()) + return 0; + + // update sprites + Texture2D texture = content.Load<Texture2D>(key); + foreach (var entry in critters) + this.SetSpriteTexture(entry.sprite, texture); + + return critters.Length; + } + + /// <summary>Reload the data for matching farm animals.</summary> + /// <returns>Returns whether any farm animals were affected.</returns> + /// <remarks>Derived from the <see cref="FarmAnimal"/> constructor.</remarks> + private bool ReloadFarmAnimalData() + { + bool changed = false; + foreach (FarmAnimal animal in this.GetFarmAnimals()) + { + animal.reloadData(); + changed = true; + } + + return changed; + } + /// <summary>Reload the sprites for a fence type.</summary> /// <param name="key">The asset key to reload.</param> /// <returns>Returns whether any textures were reloaded.</returns> @@ -501,7 +690,7 @@ namespace StardewModdingAPI.Metadata // update fence textures foreach (Fence fence in fences) - this.Reflection.GetField<Lazy<Texture2D>>(fence, "fenceTexture").SetValue(new Lazy<Texture2D>(fence.loadFenceTexture)); + fence.fenceTexture = new Lazy<Texture2D>(fence.loadFenceTexture); return true; } @@ -511,71 +700,68 @@ namespace StardewModdingAPI.Metadata /// <returns>Returns whether any NPCs were affected.</returns> private bool ReloadNpcDispositions(LocalizedContentManager content, string key) { - IDictionary<string, string> dispositions = content.Load<Dictionary<string, string>>(key); - foreach (NPC character in this.GetCharacters()) + IDictionary<string, string> data = content.Load<Dictionary<string, string>>(key); + bool changed = false; + foreach (NPC npc in this.GetCharacters()) { - if (!character.isVillager() || !dispositions.ContainsKey(character.Name)) - continue; - - NPC clone = new NPC(null, character.Position, character.DefaultMap, character.FacingDirection, character.Name, null, character.Portrait, eventActor: false); - character.Age = clone.Age; - character.Manners = clone.Manners; - character.SocialAnxiety = clone.SocialAnxiety; - character.Optimism = clone.Optimism; - character.Gender = clone.Gender; - character.datable.Value = clone.datable.Value; - character.homeRegion = clone.homeRegion; - character.Birthday_Season = clone.Birthday_Season; - character.Birthday_Day = clone.Birthday_Day; - character.id = clone.id; - character.displayName = clone.displayName; + if (npc.isVillager() && data.ContainsKey(npc.Name)) + { + npc.reloadData(); + changed = true; + } } - return true; + return changed; } /// <summary>Reload the sprites for matching NPCs.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> - /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadNpcSprites(LocalizedContentManager content, string key) + /// <param name="keys">The asset keys to reload.</param> + /// <returns>Returns the number of reloaded assets.</returns> + private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys) { // get NPCs + HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase); NPC[] characters = this.GetCharacters() - .Where(npc => npc.Sprite != null && this.GetNormalisedPath(npc.Sprite.textureName.Value) == key) + .Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name))) .ToArray(); if (!characters.Any()) - return false; + return 0; - // update portrait - Texture2D texture = content.Load<Texture2D>(key); - foreach (NPC character in characters) - this.SetSpriteTexture(character.Sprite, texture); - return true; + // update sprite + int reloaded = 0; + foreach (NPC npc in characters) + { + this.SetSpriteTexture(npc.Sprite, content.Load<Texture2D>(npc.Sprite.textureName.Value)); + reloaded++; + } + + return reloaded; } /// <summary>Reload the portraits for matching NPCs.</summary> /// <param name="content">The content manager through which to reload the asset.</param> - /// <param name="key">The asset key to reload.</param> - /// <returns>Returns whether any textures were reloaded.</returns> - private bool ReloadNpcPortraits(LocalizedContentManager content, string key) + /// <param name="keys">The asset key to reload.</param> + /// <returns>Returns the number of reloaded assets.</returns> + private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys) { // get NPCs - NPC[] villagers = this.GetCharacters() - .Where(npc => npc.isVillager() && this.GetNormalisedPath($"Portraits\\{this.Reflection.GetMethod(npc, "getTextureName").Invoke<string>()}") == key) + HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase); + var villagers = this + .GetCharacters() + .Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name))) .ToArray(); if (!villagers.Any()) - return false; + return 0; // update portrait - Texture2D texture = content.Load<Texture2D>(key); - foreach (NPC villager in villagers) + int reloaded = 0; + foreach (NPC npc in villagers) { - villager.resetPortrait(); - villager.Portrait = texture; + npc.Portrait = content.Load<Texture2D>(npc.Portrait.Name); + reloaded++; } - - return true; + return reloaded; } /// <summary>Reload tree textures.</summary> @@ -594,7 +780,7 @@ namespace StardewModdingAPI.Metadata { Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); foreach (Tree tree in trees) - this.Reflection.GetField<Lazy<Texture2D>>(tree, "texture").SetValue(texture); + tree.texture = texture; return true; } @@ -636,6 +822,8 @@ namespace StardewModdingAPI.Metadata foreach (NPC villager in villagers) { // reload schedule + this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false); + this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null); villager.Schedule = villager.getSchedule(Game1.dayOfMonth); if (villager.Schedule == null) { @@ -647,7 +835,7 @@ namespace StardewModdingAPI.Metadata int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault(); if (lastScheduleTime != 0) { - this.Reflection.GetField<int>(villager, "scheduleTimeToTry").SetValue(this.Reflection.GetField<int>(typeof(NPC), "NO_TRY").GetValue()); // use time that's passed in to checkSchedule + villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule villager.checkSchedule(lastScheduleTime); } } @@ -712,17 +900,30 @@ namespace StardewModdingAPI.Metadata } } - /// <summary>Get whether a key starts with a substring after the substring is normalised.</summary> + /// <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) + { + if (string.IsNullOrWhiteSpace(path)) + return null; + + return this.AssertAndNormalizeAssetName(path); + } + + /// <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 normalise and find.</param> + /// <param name="rawSubstring">The substring to normalize and find.</param> private bool KeyStartsWith(string key, string rawSubstring) { - return key.StartsWith(this.GetNormalisedPath(rawSubstring), StringComparison.InvariantCultureIgnoreCase); + if (string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(rawSubstring)) + return false; + + return key.StartsWith(this.NormalizeAssetNameIgnoringEmpty(rawSubstring), StringComparison.InvariantCultureIgnoreCase); } - /// <summary>Get whether a normalised asset key is in the given folder.</summary> - /// <param name="key">The normalised asset key (like <c>Animals/cat</c>).</param> - /// <param name="folder">The key folder (like <c>Animals</c>); doesn't need to be normalised.</param> + /// <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) { diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs index 272ceb09..95482708 100644 --- a/src/SMAPI/Metadata/InstructionMetadata.cs +++ b/src/SMAPI/Metadata/InstructionMetadata.cs @@ -48,14 +48,11 @@ namespace StardewModdingAPI.Metadata ****/ yield return new TypeFinder("Harmony.HarmonyInstance", InstructionHandleResult.DetectedGamePatch); yield return new TypeFinder("System.Runtime.CompilerServices.CallSite", InstructionHandleResult.DetectedDynamic); - yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerialiser); - yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerialiser); - yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.locationSerializer), InstructionHandleResult.DetectedSaveSerialiser); - yield return new EventFinder(typeof(ISpecialisedEvents).FullName, nameof(ISpecialisedEvents.UnvalidatedUpdateTicked), InstructionHandleResult.DetectedUnvalidatedUpdateTick); - yield return new EventFinder(typeof(ISpecialisedEvents).FullName, nameof(ISpecialisedEvents.UnvalidatedUpdateTicking), InstructionHandleResult.DetectedUnvalidatedUpdateTick); -#if !SMAPI_3_0_STRICT - yield return new EventFinder(typeof(SpecialisedEvents).FullName, nameof(SpecialisedEvents.UnvalidatedUpdateTick), InstructionHandleResult.DetectedUnvalidatedUpdateTick); -#endif + yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.serializer), InstructionHandleResult.DetectedSaveSerializer); + yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.farmerSerializer), InstructionHandleResult.DetectedSaveSerializer); + yield return new FieldFinder(typeof(SaveGame).FullName, nameof(SaveGame.locationSerializer), InstructionHandleResult.DetectedSaveSerializer); + yield return new EventFinder(typeof(ISpecializedEvents).FullName, nameof(ISpecializedEvents.UnvalidatedUpdateTicked), InstructionHandleResult.DetectedUnvalidatedUpdateTick); + yield return new EventFinder(typeof(ISpecializedEvents).FullName, nameof(ISpecializedEvents.UnvalidatedUpdateTicking), InstructionHandleResult.DetectedUnvalidatedUpdateTick); /**** ** detect paranoid issues diff --git a/src/SMAPI/Mod.cs b/src/SMAPI/Mod.cs index 3a753afc..0e5be1c1 100644 --- a/src/SMAPI/Mod.cs +++ b/src/SMAPI/Mod.cs @@ -41,7 +41,7 @@ namespace StardewModdingAPI ** Private methods *********/ /// <summary>Release or reset unmanaged resources when the game exits. There's no guarantee this will be called on every exit.</summary> - /// <param name="disposing">Whether the instance is being disposed explicitly rather than finalised. If this is false, the instance shouldn't dispose other objects since they may already be finalised.</param> + /// <param name="disposing">Whether the instance is being disposed explicitly rather than finalized. If this is false, the instance shouldn't dispose other objects since they may already be finalized.</param> protected virtual void Dispose(bool disposing) { } /// <summary>Destruct the instance.</summary> diff --git a/src/SMAPI/Patches/DialogueErrorPatch.cs b/src/SMAPI/Patches/DialogueErrorPatch.cs index f1c25c05..24f97259 100644 --- a/src/SMAPI/Patches/DialogueErrorPatch.cs +++ b/src/SMAPI/Patches/DialogueErrorPatch.cs @@ -10,6 +10,9 @@ using StardewValley; namespace StardewModdingAPI.Patches { /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> + /// <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 DialogueErrorPatch : IHarmonyPatch { /********* @@ -29,7 +32,7 @@ namespace StardewModdingAPI.Patches ** Accessors *********/ /// <summary>A unique name for this patch.</summary> - public string Name => $"{nameof(DialogueErrorPatch)}"; + public string Name => nameof(DialogueErrorPatch); /********* @@ -68,8 +71,6 @@ namespace StardewModdingAPI.Patches /// <param name="masterDialogue">The dialogue being parsed.</param> /// <param name="speaker">The NPC for which the dialogue is being parsed.</param> /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] private static bool Before_Dialogue_Constructor(Dialogue __instance, string masterDialogue, NPC speaker) { // get private members @@ -109,8 +110,6 @@ namespace StardewModdingAPI.Patches /// <param name="__result">The return value of the original method.</param> /// <param name="__originalMethod">The method being wrapped.</param> /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] private static bool Before_NPC_CurrentDialogue(NPC __instance, ref Stack<Dialogue> __result, MethodInfo __originalMethod) { if (DialogueErrorPatch.IsInterceptingCurrentDialogue) diff --git a/src/SMAPI/Patches/EventErrorPatch.cs b/src/SMAPI/Patches/EventErrorPatch.cs index cd530616..1dc7e8c3 100644 --- a/src/SMAPI/Patches/EventErrorPatch.cs +++ b/src/SMAPI/Patches/EventErrorPatch.cs @@ -7,6 +7,9 @@ using StardewValley; namespace StardewModdingAPI.Patches { /// <summary>A Harmony patch for the <see cref="Dialogue"/> constructor which intercepts invalid dialogue lines and logs an error instead of crashing.</summary> + /// <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 EventErrorPatch : IHarmonyPatch { /********* @@ -23,7 +26,7 @@ namespace StardewModdingAPI.Patches ** Accessors *********/ /// <summary>A unique name for this patch.</summary> - public string Name => $"{nameof(EventErrorPatch)}"; + public string Name => nameof(EventErrorPatch); /********* @@ -56,8 +59,6 @@ namespace StardewModdingAPI.Patches /// <param name="precondition">The precondition to be parsed.</param> /// <param name="__originalMethod">The method being wrapped.</param> /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] private static bool Before_GameLocation_CheckEventPrecondition(GameLocation __instance, ref int __result, string precondition, MethodInfo __originalMethod) { if (EventErrorPatch.IsIntercepted) diff --git a/src/SMAPI/Patches/LoadContextPatch.cs b/src/SMAPI/Patches/LoadContextPatch.cs index 3f86c9a9..0cc8c8eb 100644 --- a/src/SMAPI/Patches/LoadContextPatch.cs +++ b/src/SMAPI/Patches/LoadContextPatch.cs @@ -1,17 +1,19 @@ using System; -using System.Collections.ObjectModel; -using System.Collections.Specialized; +using System.Diagnostics.CodeAnalysis; using Harmony; using StardewModdingAPI.Enums; using StardewModdingAPI.Framework.Patching; using StardewModdingAPI.Framework.Reflection; using StardewValley; using StardewValley.Menus; +using StardewValley.Minigames; namespace StardewModdingAPI.Patches { - /// <summary>A Harmony patch for <see cref="Game1.loadForNewGame"/> which notifies SMAPI for save creation load stages.</summary> - /// <remarks>This patch hooks into <see cref="Game1.loadForNewGame"/>, checks if <c>TitleMenu.transitioningCharacterCreationMenu</c> is true (which means the player is creating a new save file), then raises <see cref="LoadStage.CreatedBasicInfo"/> after the location list is cleared twice (the second clear happens right before locations are created), and <see cref="LoadStage.CreatedLocations"/> when the method ends.</remarks> + /// <summary>Harmony patches which notify SMAPI for save creation load stages.</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 LoadContextPatch : IHarmonyPatch { /********* @@ -23,18 +25,12 @@ namespace StardewModdingAPI.Patches /// <summary>A callback to invoke when the load stage changes.</summary> private static Action<LoadStage> OnStageChanged; - /// <summary>Whether <see cref="Game1.loadForNewGame"/> was called as part of save creation.</summary> - private static bool IsCreating; - - /// <summary>The number of times that <see cref="Game1.locations"/> has been cleared since <see cref="Game1.loadForNewGame"/> started.</summary> - private static int TimesLocationsCleared; - /********* ** Accessors *********/ /// <summary>A unique name for this patch.</summary> - public string Name => $"{nameof(LoadContextPatch)}"; + public string Name => nameof(LoadContextPatch); /********* @@ -53,9 +49,15 @@ namespace StardewModdingAPI.Patches /// <param name="harmony">The Harmony instance.</param> public void Apply(HarmonyInstance harmony) { + // detect CreatedBasicInfo + harmony.Patch( + original: AccessTools.Method(typeof(TitleMenu), nameof(TitleMenu.createdNewCharacter)), + prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_TitleMenu_CreatedNewCharacter)) + ); + + // detect CreatedLocations harmony.Patch( original: AccessTools.Method(typeof(Game1), nameof(Game1.loadForNewGame)), - prefix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.Before_Game1_LoadForNewGame)), postfix: new HarmonyMethod(this.GetType(), nameof(LoadContextPatch.After_Game1_LoadForNewGame)) ); } @@ -64,45 +66,25 @@ namespace StardewModdingAPI.Patches /********* ** Private methods *********/ - /// <summary>The method to call instead of <see cref="Game1.loadForNewGame"/>.</summary> + /// <summary>Called before <see cref="TitleMenu.createdNewCharacter"/>.</summary> /// <returns>Returns whether to execute the original method.</returns> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - private static bool Before_Game1_LoadForNewGame() + private static bool Before_TitleMenu_CreatedNewCharacter() { - LoadContextPatch.IsCreating = Game1.activeClickableMenu is TitleMenu menu && LoadContextPatch.Reflection.GetField<bool>(menu, "transitioningCharacterCreationMenu").GetValue(); - LoadContextPatch.TimesLocationsCleared = 0; - if (LoadContextPatch.IsCreating) - { - // raise CreatedBasicInfo after locations are cleared twice - ObservableCollection<GameLocation> locations = (ObservableCollection<GameLocation>)Game1.locations; - locations.CollectionChanged += LoadContextPatch.OnLocationListChanged; - } - + LoadContextPatch.OnStageChanged(LoadStage.CreatedBasicInfo); return true; } - /// <summary>The method to call instead after <see cref="Game1.loadForNewGame"/>.</summary> + /// <summary>Called after <see cref="Game1.loadForNewGame"/>.</summary> /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> private static void After_Game1_LoadForNewGame() { - if (LoadContextPatch.IsCreating) - { - // clean up - ObservableCollection<GameLocation> locations = (ObservableCollection<GameLocation>)Game1.locations; - locations.CollectionChanged -= LoadContextPatch.OnLocationListChanged; + bool creating = + (Game1.currentMinigame is Intro) // creating save with intro + || (Game1.activeClickableMenu is TitleMenu menu && LoadContextPatch.Reflection.GetField<bool>(menu, "transitioningCharacterCreationMenu").GetValue()); // creating save, skipped intro - // raise stage changed + if (creating) LoadContextPatch.OnStageChanged(LoadStage.CreatedLocations); - } - } - - /// <summary>Raised when <see cref="Game1.locations"/> changes.</summary> - /// <param name="sender">The event sender.</param> - /// <param name="e">The event arguments.</param> - private static void OnLocationListChanged(object sender, NotifyCollectionChangedEventArgs e) - { - if (++LoadContextPatch.TimesLocationsCleared == 2) - LoadContextPatch.OnStageChanged(LoadStage.CreatedBasicInfo); } } } diff --git a/src/SMAPI/Patches/LoadErrorPatch.cs b/src/SMAPI/Patches/LoadErrorPatch.cs new file mode 100644 index 00000000..eedb4164 --- /dev/null +++ b/src/SMAPI/Patches/LoadErrorPatch.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Harmony; +using StardewModdingAPI.Framework.Patching; +using StardewValley; +using StardewValley.Locations; + +namespace StardewModdingAPI.Patches +{ + /// <summary>A Harmony patch for <see cref="SaveGame"/> which prevents some errors due to broken save data.</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 LoadErrorPatch : IHarmonyPatch + { + /********* + ** Fields + *********/ + /// <summary>Writes messages to the console and log file.</summary> + private static IMonitor Monitor; + + /// <summary>A callback invoked when custom content is removed from the save data to avoid a crash.</summary> + private static Action OnContentRemoved; + + + /********* + ** Accessors + *********/ + /// <summary>A unique name for this patch.</summary> + public string Name => nameof(LoadErrorPatch); + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="monitor">Writes messages to the console and log file.</param> + /// <param name="onContentRemoved">A callback invoked when custom content is removed from the save data to avoid a crash.</param> + public LoadErrorPatch(IMonitor monitor, Action onContentRemoved) + { + LoadErrorPatch.Monitor = monitor; + LoadErrorPatch.OnContentRemoved = onContentRemoved; + } + + + /// <summary>Apply the Harmony patch.</summary> + /// <param name="harmony">The Harmony instance.</param> + public void Apply(HarmonyInstance harmony) + { + harmony.Patch( + original: AccessTools.Method(typeof(SaveGame), nameof(SaveGame.loadDataToLocations)), + prefix: new HarmonyMethod(this.GetType(), nameof(LoadErrorPatch.Before_SaveGame_LoadDataToLocations)) + ); + } + + + /********* + ** Private methods + *********/ + /// <summary>The method to call instead of <see cref="SaveGame.loadDataToLocations"/>.</summary> + /// <param name="gamelocations">The game locations being loaded.</param> + /// <returns>Returns whether to execute the original method.</returns> + private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations) + { + bool removedAny = false; + + // remove invalid locations + foreach (GameLocation location in gamelocations.ToArray()) + { + if (location is Cellar) + continue; // missing cellars will be added by the game code + + if (Game1.getLocationFromName(location.name) == null) + { + LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn); + gamelocations.Remove(location); + removedAny = true; + } + } + + // get building interiors + var interiors = + ( + from location in gamelocations.OfType<BuildableGameLocation>() + from building in location.buildings + where building.indoors.Value != null + select building.indoors.Value + ); + + // remove custom NPCs which no longer exist + IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions"); + foreach (GameLocation location in gamelocations.Concat(interiors)) + { + foreach (NPC npc in location.characters.ToArray()) + { + if (npc.isVillager() && !data.ContainsKey(npc.Name)) + { + try + { + npc.reloadSprite(); // this won't crash for special villagers like Bouncer + } + catch + { + LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn); + location.characters.Remove(npc); + removedAny = true; + } + } + } + } + + if (removedAny) + LoadErrorPatch.OnContentRemoved(); + + return true; + } + } +} diff --git a/src/SMAPI/Patches/ObjectErrorPatch.cs b/src/SMAPI/Patches/ObjectErrorPatch.cs index 5b918d39..d716b29b 100644 --- a/src/SMAPI/Patches/ObjectErrorPatch.cs +++ b/src/SMAPI/Patches/ObjectErrorPatch.cs @@ -8,13 +8,16 @@ using SObject = StardewValley.Object; namespace StardewModdingAPI.Patches { /// <summary>A Harmony patch for <see cref="SObject.getDescription"/> which intercepts crashes due to the item no longer existing.</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 ObjectErrorPatch : IHarmonyPatch { /********* ** Accessors *********/ /// <summary>A unique name for this patch.</summary> - public string Name => $"{nameof(ObjectErrorPatch)}"; + public string Name => nameof(ObjectErrorPatch); /********* @@ -45,8 +48,6 @@ namespace StardewModdingAPI.Patches /// <param name="__instance">The instance being patched.</param> /// <param name="__result">The patched method's return value.</param> /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] private static bool Before_Object_GetDescription(SObject __instance, ref string __result) { // invalid bigcraftables crash instead of showing '???' like invalid non-bigcraftables @@ -63,8 +64,6 @@ namespace StardewModdingAPI.Patches /// <param name="__instance">The instance being patched.</param> /// <param name="hoveredItem">The item for which to draw a tooltip.</param> /// <returns>Returns whether to execute the original method.</returns> - /// <remarks>This method must be static for Harmony to work correctly. See the Harmony documentation before renaming arguments.</remarks> - [SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony.")] private static bool Before_IClickableMenu_DrawTooltip(IClickableMenu __instance, Item hoveredItem) { // invalid edible item cause crash when drawing tooltips diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 3a34872a..6bacf564 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -3,12 +3,15 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Reflection; +using System.Runtime.CompilerServices; using System.Threading; #if SMAPI_FOR_WINDOWS #endif using StardewModdingAPI.Framework; -using StardewModdingAPI.Internal; +using StardewModdingAPI.Toolkit.Utilities; +[assembly: InternalsVisibleTo("SMAPI.Tests")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing namespace StardewModdingAPI { /// <summary>The main entry point for SMAPI, responsible for hooking into and launching the game.</summary> @@ -37,9 +40,14 @@ namespace StardewModdingAPI Program.AssertGameVersion(); Program.Start(args); } + catch (BadImageFormatException ex) when (ex.FileName == "StardewValley") + { + string executableName = Program.GetExecutableAssemblyName(); + Console.WriteLine($"SMAPI failed to initialize because your game's {executableName}.exe seems to be invalid.\nThis may be a pirated version which modified the executable in an incompatible way; if so, you can try a different download or buy a legitimate version.\n\nTechnical details:\n{ex}"); + } catch (Exception ex) { - Console.WriteLine($"SMAPI failed to initialise: {ex}"); + Console.WriteLine($"SMAPI failed to initialize: {ex}"); Program.PressAnyKeyToExit(true); } } @@ -74,19 +82,9 @@ namespace StardewModdingAPI /// <remarks>This must be checked *before* any references to <see cref="Constants"/>, and this method should not reference <see cref="Constants"/> itself to avoid errors in Mono.</remarks> private static void AssertGamePresent() { - Platform platform = EnvironmentUtility.DetectPlatform(); - string gameAssemblyName = platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; + string gameAssemblyName = Program.GetExecutableAssemblyName(); if (Type.GetType($"StardewValley.Game1, {gameAssemblyName}", throwOnError: false) == null) - { - Program.PrintErrorAndExit( - "Oops! SMAPI can't find the game. " - + (Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Windows")) || Assembly.GetCallingAssembly().Location.Contains(Path.Combine("internal", "Mono")) - ? "It looks like you're running SMAPI from the download package, but you need to run the installed version instead. " - : "Make sure you're running StardewModdingAPI.exe in your game folder. " - ) - + "See the readme.txt file for details." - ); - } + Program.PrintErrorAndExit("Oops! SMAPI can't find the game. Make sure you're running StardewModdingAPI.exe in your game folder. See the readme.txt file for details."); } /// <summary>Assert that the game version is within <see cref="Constants.MinimumGameVersion"/> and <see cref="Constants.MaximumGameVersion"/>.</summary> @@ -108,26 +106,39 @@ namespace StardewModdingAPI } - /// <summary>Initialise SMAPI and launch the game.</summary> + /// <summary>Get the game's executable assembly name.</summary> + private static string GetExecutableAssemblyName() + { + Platform platform = EnvironmentUtility.DetectPlatform(); + return platform == Platform.Windows ? "Stardew Valley" : "StardewValley"; + } + + /// <summary>Initialize SMAPI and launch the game.</summary> /// <param name="args">The command-line arguments.</param> /// <remarks>This method is separate from <see cref="Main"/> because that can't contain any references to assemblies loaded by <see cref="CurrentDomain_AssemblyResolve"/> (e.g. via <see cref="Constants"/>), or Mono will incorrectly show an assembly resolution error before assembly resolution is set up.</remarks> private static void Start(string[] args) { - // get flags from arguments - bool writeToConsole = !args.Contains("--no-terminal"); + // get flags + bool writeToConsole = !args.Contains("--no-terminal") && Environment.GetEnvironmentVariable("SMAPI_NO_TERMINAL") == null; - // get mods path from arguments - string modsPath = null; + // get mods path + string modsPath; { + string rawModsPath = null; + + // get from command line args int pathIndex = Array.LastIndexOf(args, "--mods-path") + 1; if (pathIndex >= 1 && args.Length >= pathIndex) - { - modsPath = args[pathIndex]; - if (!string.IsNullOrWhiteSpace(modsPath) && !Path.IsPathRooted(modsPath)) - modsPath = Path.Combine(Constants.ExecutionPath, modsPath); - } - if (string.IsNullOrWhiteSpace(modsPath)) - modsPath = Constants.DefaultModsPath; + rawModsPath = args[pathIndex]; + + // get from environment variables + if (string.IsNullOrWhiteSpace(rawModsPath)) + rawModsPath = Environment.GetEnvironmentVariable("SMAPI_MODS_PATH"); + + // normalise + modsPath = !string.IsNullOrWhiteSpace(rawModsPath) + ? Path.Combine(Constants.ExecutionPath, rawModsPath) + : Constants.DefaultModsPath; } // load SMAPI diff --git a/src/SMAPI/Properties/AssemblyInfo.cs b/src/SMAPI/Properties/AssemblyInfo.cs deleted file mode 100644 index 03843ea8..00000000 --- a/src/SMAPI/Properties/AssemblyInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using System.Reflection; -using System.Runtime.CompilerServices; - -[assembly: AssemblyTitle("SMAPI")] -[assembly: AssemblyDescription("A modding API for Stardew Valley.")] -[assembly: InternalsVisibleTo("StardewModdingAPI.Tests")] -[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/SMAPI.config.json index c04cceee..bccac678 100644 --- a/src/SMAPI/StardewModdingAPI.config.json +++ b/src/SMAPI/SMAPI.config.json @@ -3,18 +3,16 @@ This file contains advanced configuration for SMAPI. You generally shouldn't change this file. +The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes. */ { /** - * The console color theme to use. The possible values are: - * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color automatically on Linux or Windows. - * - LightBackground: use darker text colors that look better on a white or light background. - * - DarkBackground: use lighter text colors that look better on a black or dark background. + * Whether SMAPI should log more information about the game context. */ - "ColorScheme": "AutoDetect", + "VerboseLogging": false, /** * Whether SMAPI should check for newer versions of SMAPI and mods when you load the game. If new @@ -57,9 +55,9 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "WebApiBaseUrl": "https://api.smapi.io", /** - * Whether SMAPI should log more information about the game context. + * Whether SMAPI should log network traffic (may be very verbose). Best combined with VerboseLogging, which includes network metadata. */ - "VerboseLogging": false, + "LogNetworkTraffic": false, /** * Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod @@ -68,6 +66,45 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha "DumpMetadata": false, /** + * The colors to use for text written to the SMAPI console. + * + * The possible values for 'UseScheme' are: + * - AutoDetect: SMAPI will assume a light background on Mac, and detect the background color + * automatically on Linux or Windows. + * - LightBackground: use darker text colors that look better on a white or light background. + * - DarkBackground: use lighter text colors that look better on a black or dark background. + * + * For available color codes, see https://docs.microsoft.com/en-us/dotnet/api/system.consolecolor. + * + * (These values are synched with ColorfulConsoleWriter.GetDefaultColorSchemeConfig in the + * SMAPI code.) + */ + "ConsoleColors": { + "UseScheme": "AutoDetect", + + "Schemes": { + "DarkBackground": { + "Trace": "DarkGray", + "Debug": "DarkGray", + "Info": "White", + "Warn": "Yellow", + "Error": "Red", + "Alert": "Magenta", + "Success": "DarkGreen" + }, + "LightBackground": { + "Trace": "DarkGray", + "Debug": "DarkGray", + "Info": "Black", + "Warn": "DarkYellow", + "Error": "Red", + "Alert": "DarkMagenta", + "Success": "DarkGreen" + } + } + }, + + /** * The mod IDs SMAPI should ignore when performing update checks or validating update keys. */ "SuppressUpdateChecks": [ diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj new file mode 100644 index 00000000..4952116f --- /dev/null +++ b/src/SMAPI/SMAPI.csproj @@ -0,0 +1,113 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <AssemblyName>StardewModdingAPI</AssemblyName> + <RootNamespace>StardewModdingAPI</RootNamespace> + <Description>The modding API for Stardew Valley.</Description> + <TargetFramework>net45</TargetFramework> + <LangVersion>latest</LangVersion> + <PlatformTarget>x86</PlatformTarget> + <OutputType>Exe</OutputType> + <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</OutputPath> + <DocumentationFile>$(SolutionDir)\..\bin\$(Configuration)\SMAPI\StardewModdingAPI.xml</DocumentationFile> + <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> + <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware> + <ApplicationIcon>icon.ico</ApplicationIcon> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="LargeAddressAware" Version="1.0.3" /> + <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> + <PackageReference Include="Mono.Cecil" Version="0.11.1" /> + <PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> + </ItemGroup> + + <ItemGroup> + <Reference Include="$(GameExecutableName)"> + <HintPath>$(GamePath)\$(GameExecutableName).exe</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="StardewValley.GameData"> + <HintPath>$(GamePath)\StardewValley.GameData.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="System.Numerics"> + <Private>True</Private> + </Reference> + <Reference Include="System.Runtime.Caching"> + <Private>True</Private> + </Reference> + <Reference Include="GalaxyCSharp"> + <HintPath>$(GamePath)\GalaxyCSharp.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="Lidgren.Network"> + <HintPath>$(GamePath)\Lidgren.Network.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="xTile"> + <HintPath>$(GamePath)\xTile.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + + <Choose> + <!-- Windows --> + <When Condition="$(OS) == 'Windows_NT'"> + <ItemGroup> + <Reference Include="Netcode"> + <HintPath>$(GamePath)\Netcode.dll</HintPath> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> + <Private>False</Private> + </Reference> + <Reference Include="System.Windows.Forms" /> + </ItemGroup> + </When> + + <!-- Linux/Mac --> + <Otherwise> + <ItemGroup> + <Reference Include="MonoGame.Framework"> + <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> + <Private>False</Private> + </Reference> + </ItemGroup> + </Otherwise> + </Choose> + + <ItemGroup> + <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\SMAPI.Toolkit.CoreInterfaces.csproj" /> + <ProjectReference Include="..\SMAPI.Toolkit\SMAPI.Toolkit.csproj" /> + </ItemGroup> + + <ItemGroup> + <Content Include="SMAPI.config.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <Content Include="..\SMAPI.Web\wwwroot\SMAPI.metadata.json"> + <Link>SMAPI.metadata.json</Link> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </Content> + <None Update="i18n\default.json"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + <None Update="steam_appid.txt"> + <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> + </None> + </ItemGroup> + + <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> + <Import Project="..\..\build\common.targets" /> + +</Project> diff --git a/src/SMAPI/SemanticVersion.cs b/src/SMAPI/SemanticVersion.cs index ec2d9e40..0db41673 100644 --- a/src/SMAPI/SemanticVersion.cs +++ b/src/SMAPI/SemanticVersion.cs @@ -1,6 +1,5 @@ using System; using Newtonsoft.Json; -using StardewModdingAPI.Framework; namespace StardewModdingAPI { @@ -26,19 +25,6 @@ namespace StardewModdingAPI /// <summary>The patch version for backwards-compatible bug fixes.</summary> public int PatchVersion => this.Version.PatchVersion; -#if !SMAPI_3_0_STRICT - /// <summary>An optional build tag.</summary> - [Obsolete("Use " + nameof(ISemanticVersion.PrereleaseTag) + " instead")] - public string Build - { - get - { - SCore.DeprecationManager?.Warn($"{nameof(ISemanticVersion)}.{nameof(ISemanticVersion.Build)}", "2.8", DeprecationLevel.PendingRemoval); - return this.Version.PrereleaseTag; - } - } -#endif - /// <summary>An optional prerelease tag.</summary> public string PrereleaseTag => this.Version.PrereleaseTag; @@ -75,13 +61,13 @@ namespace StardewModdingAPI this.Version = version; } - /// <summary>Whether this is a pre-release version.</summary> + /// <summary>Whether this is a prerelease version.</summary> public bool IsPrerelease() { return this.Version.IsPrerelease(); } - /// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary> + /// <summary>Get an integer indicating whether this version precedes (less than 0), supersedes (more than 0), or is equivalent to (0) the specified version.</summary> /// <param name="other">The version to compare with this instance.</param> /// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception> /// <remarks>The implementation is defined by Semantic Version 2.0 (https://semver.org/).</remarks> @@ -155,7 +141,7 @@ namespace StardewModdingAPI /// <param name="version">The version string.</param> /// <param name="parsed">The parsed representation.</param> /// <returns>Returns whether parsing the version succeeded.</returns> - internal static bool TryParse(string version, out ISemanticVersion parsed) + public static bool TryParse(string version, out ISemanticVersion parsed) { if (Toolkit.SemanticVersion.TryParse(version, out ISemanticVersion versionImpl)) { diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj deleted file mode 100644 index eda53025..00000000 --- a/src/SMAPI/StardewModdingAPI.csproj +++ /dev/null @@ -1,60 +0,0 @@ -<Project Sdk="Microsoft.NET.Sdk"> - - <PropertyGroup> - <RootNamespace>StardewModdingAPI</RootNamespace> - <AssemblyName>StardewModdingAPI</AssemblyName> - <TargetFramework>net45</TargetFramework> - <GenerateAssemblyInfo>false</GenerateAssemblyInfo> - <LangVersion>latest</LangVersion> - <PlatformTarget>x86</PlatformTarget> - <OutputType>Exe</OutputType> - <OutputPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</OutputPath> - <DocumentationFile>$(SolutionDir)\..\bin\$(Configuration)\SMAPI\StardewModdingAPI.xml</DocumentationFile> - <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> - <LargeAddressAware Condition="'$(OS)' == 'Windows_NT'">true</LargeAddressAware> - <ApplicationIcon>icon.ico</ApplicationIcon> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="LargeAddressAware" Version="1.0.3" /> - <PackageReference Include="Lib.Harmony" Version="1.2.0.1" /> - <PackageReference Include="Mono.Cecil" Version="0.10.1" /> - <PackageReference Include="Newtonsoft.Json" Version="11.0.2" /> - </ItemGroup> - - <ItemGroup> - <Reference Include="System.Numerics"> - <Private>True</Private> - </Reference> - <Reference Include="System.Runtime.Caching"> - <Private>True</Private> - </Reference> - <Reference Include="System.Windows.Forms" Condition="$(OS) == 'Windows_NT'" /> - </ItemGroup> - - <ItemGroup> - <ProjectReference Include="..\SMAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj" /> - <ProjectReference Include="..\SMAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" /> - </ItemGroup> - - <ItemGroup> - <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" /> - </ItemGroup> - - <ItemGroup> - <Content Include="StardewModdingAPI.config.json"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <Content Include="..\SMAPI.Web\wwwroot\StardewModdingAPI.metadata.json"> - <Link>StardewModdingAPI.metadata.json</Link> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </Content> - <None Update="steam_appid.txt"> - <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> - </None> - </ItemGroup> - - <Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" /> - <Import Project="..\..\build\common.targets" /> - -</Project> diff --git a/src/SMAPI/Translation.cs b/src/SMAPI/Translation.cs index abcdb336..2196c8a5 100644 --- a/src/SMAPI/Translation.cs +++ b/src/SMAPI/Translation.cs @@ -15,9 +15,6 @@ 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 name of the relevant mod for error messages.</summary> - private readonly string ModName; - /// <summary>The locale for which the translation was fetched.</summary> private readonly string Locale; @@ -38,37 +35,12 @@ namespace StardewModdingAPI /********* ** Public methods *********/ - /// <summary>Construct an isntance.</summary> - /// <param name="modName">The name of the relevant mod for error messages.</param> - /// <param name="locale">The locale for which the translation was fetched.</param> - /// <param name="key">The translation key.</param> - /// <param name="text">The underlying translation text.</param> - internal Translation(string modName, string locale, string key, string text) - : this(modName, locale, key, text, string.Format(Translation.PlaceholderText, key)) { } - - /// <summary>Construct an isntance.</summary> - /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <summary>Construct an instance.</summary> /// <param name="locale">The locale for which the translation was fetched.</param> /// <param name="key">The translation key.</param> /// <param name="text">The underlying translation text.</param> - /// <param name="placeholder">The value to return if the translations is undefined.</param> - internal Translation(string modName, string locale, string key, string text, string placeholder) - { - this.ModName = modName; - this.Locale = locale; - this.Key = key; - this.Text = text; - this.Placeholder = placeholder; - } - - /// <summary>Throw an exception if the translation text is <c>null</c> or empty.</summary> - /// <exception cref="KeyNotFoundException">There's no available translation matching the requested key and locale.</exception> - public Translation Assert() - { - if (!this.HasValue()) - throw new KeyNotFoundException($"The '{this.ModName}' mod doesn't have a translation with key '{this.Key}' for the '{this.Locale}' locale or its fallbacks."); - return this; - } + 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> @@ -76,14 +48,14 @@ namespace StardewModdingAPI { return this.HasValue() ? this - : new Translation(this.ModName, this.Locale, this.Key, @default); + : new Translation(this.Locale, this.Key, @default); } /// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty. Returns a new instance.</summary> /// <param name="use">Whether to return a placeholder.</param> public Translation UsePlaceholder(bool use) { - return new Translation(this.ModName, this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, this.Key) : null); + return new Translation(this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, this.Key) : null); } /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> @@ -127,7 +99,7 @@ namespace StardewModdingAPI ? value : match.Value; }); - return new Translation(this.ModName, this.Locale, this.Key, text); + return new Translation(this.Locale, this.Key, text); } /// <summary>Get whether the translation has a defined value.</summary> @@ -150,5 +122,22 @@ namespace StardewModdingAPI { return translation?.ToString(); } + + + /********* + ** Private methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + /// <param name="placeholder">The value to return if the translations is undefined.</param> + private Translation(string locale, string key, string text, string placeholder) + { + this.Locale = locale; + this.Key = key; + this.Text = text; + this.Placeholder = placeholder; + } } } diff --git a/src/SMAPI/Utilities/SDate.cs b/src/SMAPI/Utilities/SDate.cs index ec54f84a..0ab37aa0 100644 --- a/src/SMAPI/Utilities/SDate.cs +++ b/src/SMAPI/Utilities/SDate.cs @@ -86,7 +86,7 @@ namespace StardewModdingAPI.Utilities seasonIndex %= 4; // get year - int year = hashCode / (this.Seasons.Length * this.DaysInSeason) + 1; + int year = (int)Math.Ceiling(hashCode / (this.Seasons.Length * this.DaysInSeason * 1m)); // create date return new SDate(day, this.Seasons[seasonIndex], year); @@ -192,12 +192,12 @@ namespace StardewModdingAPI.Utilities throw new ArgumentException($"Unknown season '{season}', must be one of [{string.Join(", ", this.Seasons)}]."); if (day < 0 || day > this.DaysInSeason) throw new ArgumentException($"Invalid day '{day}', must be a value from 1 to {this.DaysInSeason}."); - if(day == 0 && !(allowDayZero && this.IsDayZero(day, season, year))) + if (day == 0 && !(allowDayZero && this.IsDayZero(day, season, year))) throw new ArgumentException($"Invalid day '{day}', must be a value from 1 to {this.DaysInSeason}."); if (year < 1) throw new ArgumentException($"Invalid year '{year}', must be at least 1."); - // initialise + // initialize this.Day = day; this.Season = season; this.Year = year; @@ -256,12 +256,12 @@ namespace StardewModdingAPI.Utilities /// <summary>Get a season index.</summary> /// <param name="season">The season name.</param> - /// <exception cref="InvalidOperationException">The current season wasn't recognised.</exception> + /// <exception cref="InvalidOperationException">The current season wasn't recognized.</exception> private int GetSeasonIndex(string season) { int index = Array.IndexOf(this.Seasons, season); if (index == -1) - throw new InvalidOperationException($"The season '{season}' wasn't recognised."); + throw new InvalidOperationException($"The season '{season}' wasn't recognized."); return index; } } diff --git a/src/SMAPI/i18n/de.json b/src/SMAPI/i18n/de.json new file mode 100644 index 00000000..a8b3086f --- /dev/null +++ b/src/SMAPI/i18n/de.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Ungültiger Inhalt wurde entfernt, um einen Absturz zu verhindern (siehe SMAPI Konsole für weitere Informationen)." +} diff --git a/src/SMAPI/i18n/default.json b/src/SMAPI/i18n/default.json new file mode 100644 index 00000000..5a3e4a6e --- /dev/null +++ b/src/SMAPI/i18n/default.json @@ -0,0 +1,3 @@ +{ + "warn.invalid-content-removed": "Invalid content was removed to prevent a crash (see the SMAPI console for info)." +} |