diff options
34 files changed, 999 insertions, 339 deletions
diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs index e9236498..d2cf8fe7 100644 --- a/build/GlobalAssemblyInfo.cs +++ b/build/GlobalAssemblyInfo.cs @@ -1,5 +1,5 @@ using System.Reflection; [assembly: AssemblyProduct("SMAPI")] -[assembly: AssemblyVersion("2.5.3")] -[assembly: AssemblyFileVersion("2.5.3")] +[assembly: AssemblyVersion("2.5.4")] +[assembly: AssemblyFileVersion("2.5.4")] diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md index ca750c86..0d72e4d9 100644 --- a/docs/mod-build-config.md +++ b/docs/mod-build-config.md @@ -120,6 +120,19 @@ or you have multiple installs, you can specify the path yourself. There's two wa The configuration will check your custom path first, then fall back to the default paths (so it'll still compile on a different computer). +### Unit test projects +**(upcoming in 2.0.3)** + +You can use the package in unit test projects too. Its optional unit test mode... + +1. disables deploying the project as a mod; +2. disables creating a release zip; +2. and copies the referenced DLLs into the build output for unit test frameworks. + +To enable it, add this above the first `</PropertyGroup>` in your `.csproj`: +```xml +<ModUnitTests>True</ModUnitTests> +``` ## Troubleshoot ### "Failed to find the game install path" @@ -127,6 +140,16 @@ That error means the package couldn't find your game. You can specify the game p _[Game path](#game-path)_ above. ## Release notes +### 2.0.3 alpha +* Added support for Stardew Valley 1.3. +* Added support for unit test projects. + +### 2.0.2 +* Fixed compatibility issue on Linux. + +### 2.0.1 +* Fixed mod deploy failing to create subfolders if they don't already exist. + ### 2.0 * Added: mods are now copied into the `Mods` folder automatically (configurable). * Added: release zips are now created automatically in your build output folder (configurable). diff --git a/docs/release-notes.md b/docs/release-notes.md index fdc06c87..b3300800 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -1,18 +1,43 @@ # Release notes +<!-- ## 2.6 alpha * For players: - * Updated for Stardew Valley 1.3 (multiplayer update); no longer compatible with earlier versions. + * Added support for Stardew Valley 1.3+; no longer compatible with earlier versions. + * Fixed SMAPI update alerts linking to the GitHub repository instead of [smapi.io](https://smapi.io). + * Fixed SMAPI update checks not showing newer beta versions when using a beta version. * For modders: - * Dropped support for some deprecated APIs. + * Dropped some deprecated APIs. * Fixed some assets not being editable. +* For SMAPI developers: + * Added prerelease versions to the mod update-check API response where available (GitHub only). + * Added support for beta releases on the home page. +--> + +## 2.5.4 +* For players: + * Fixed some textures not updated when a mod changes them. + * Fixed visual bug on Linux/Mac when mods overlay textures. + * Fixed error when mods remove an asset editor/loader. + * Fixed minimum game version incorrectly increased in SMAPI 2.5.3. + +* For the [log parser][]: + * Fixed error when log text contains certain tokens. + +* For modders: + * Updated to Json.NET 11.0.2. + +* For SMAPI developers: + * Added support for beta update track to support upcoming Stardew Valley 1.3 beta. + ## 2.5.3 * For players: * Simplified and improved skipped-mod messages. * Fixed rare crash with some combinations of manifest fields and internal mod data. * Fixed update checks failing for Nexus Mods due to a change in their API. * Fixed update checks failing for some older mods with non-standard versions. + * Fixed failed update checks being cached for an hour (now cached 5 minutes). * Fixed error when a content pack needs a mod that couldn't be loaded. * Fixed Linux ["magic number is wrong" errors](https://github.com/mono/mono/issues/6752) by changing default terminal order. * Updated compatibility list and added update checks for more mods. diff --git a/src/SMAPI.Common/Models/ModInfoModel.cs b/src/SMAPI.Common/Models/ModInfoModel.cs index 48305cb8..48df235a 100644 --- a/src/SMAPI.Common/Models/ModInfoModel.cs +++ b/src/SMAPI.Common/Models/ModInfoModel.cs @@ -9,9 +9,12 @@ namespace StardewModdingAPI.Common.Models /// <summary>The mod name.</summary> public string Name { get; set; } - /// <summary>The mod's semantic version number.</summary> + /// <summary>The semantic version for the mod's latest release.</summary> public string Version { get; set; } + /// <summary>The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</summary> + public string PreviewVersion { get; set; } + /// <summary>The mod's web URL.</summary> public string Url { get; set; } @@ -28,16 +31,17 @@ namespace StardewModdingAPI.Common.Models // needed for JSON deserialising } - /// <summary>Construct an instance.</summary> /// <param name="name">The mod name.</param> - /// <param name="version">The mod's semantic version number.</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 error = null) + public ModInfoModel(string name, string version, string url, string previewVersion = null, string error = null) { this.Name = name; this.Version = version; + this.PreviewVersion = previewVersion; this.Url = url; this.Error = error; // mainly initialised here for the JSON deserialiser } diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets index 7e8bbfc3..d2e37101 100644 --- a/src/SMAPI.ModBuildConfig/build/smapi.targets +++ b/src/SMAPI.ModBuildConfig/build/smapi.targets @@ -19,9 +19,14 @@ <!-- set default settings --> <ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName> + <ModUnitTests Condition="'$(ModUnitTests)' == ''">False</ModUnitTests> <ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath> <EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy> <EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip> + + <!-- disable mod deploy in unit test project --> + <EnableModDeploy Condition="'$(ModUnitTests)' == true">False</EnableModDeploy> + <EnableModZip Condition="'$(ModUnitTests)' == true">False</EnableModZip> </PropertyGroup> <!-- find platform + game path --> @@ -57,32 +62,40 @@ <ItemGroup> <Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86"> <Private>false</Private> + <Private Condition="$(ModUnitTests)">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="$(ModUnitTests)">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="$(ModUnitTests)">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="$(ModUnitTests)">true</Private> </Reference> <Reference Include="Netcode" Condition="Exists('$(GamePath)\Netcode.dll')"> <HintPath>$(GamePath)\Netcode.dll</HintPath> <Private>False</Private> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> <Reference Include="Stardew Valley"> <HintPath>$(GamePath)\Stardew Valley.exe</HintPath> <Private>false</Private> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> <Reference Include="StardewModdingAPI"> <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> <Private>false</Private> + <Private Condition="$(ModUnitTests)">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="$(ModUnitTests)">true</Private> </Reference> </ItemGroup> @@ -100,18 +113,22 @@ <HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath> <Private>false</Private> <SpecificVersion>False</SpecificVersion> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> <Reference Include="StardewValley"> <HintPath>$(GamePath)\StardewValley.exe</HintPath> <Private>false</Private> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> <Reference Include="StardewModdingAPI"> <HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath> <Private>false</Private> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> <Reference Include="xTile"> <HintPath>$(GamePath)\xTile.dll</HintPath> <Private>false</Private> + <Private Condition="$(ModUnitTests)">true</Private> </Reference> </ItemGroup> </Otherwise> diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec index 8393ab61..d24e15be 100644 --- a/src/SMAPI.ModBuildConfig/package.nuspec +++ b/src/SMAPI.ModBuildConfig/package.nuspec @@ -2,7 +2,7 @@ <package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd"> <metadata> <id>Pathoschild.Stardew.ModBuildConfig</id> - <version>2.0.3-alpha20180307</version> + <version>2.0.3-alpha20180325</version> <title>Build package for SMAPI mods</title> <authors>Pathoschild</authors> <owners>Pathoschild</owners> @@ -29,6 +29,7 @@ 2.0.3: - Added support for Stardew Valley 1.3. + - Added support for unit test projects. </releaseNotes> </metadata> <files> diff --git a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj index a5b89a33..d1f72c6c 100644 --- a/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj +++ b/src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj @@ -37,8 +37,8 @@ </PropertyGroup> <ItemGroup> <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.1-beta3\lib\net45\Newtonsoft.Json.dll</HintPath> - <Private>False</Private> + <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> + <Private>True</Private> </Reference> <Reference Include="System" /> <Reference Include="System.Xml" /> diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json index 785af01a..a56cf66d 100644 --- a/src/SMAPI.Mods.ConsoleCommands/manifest.json +++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json @@ -1,7 +1,7 @@ { "Name": "Console Commands", "Author": "SMAPI", - "Version": "2.5.3", + "Version": "2.5.4", "Description": "Adds SMAPI console commands that let you manipulate the game.", "UniqueID": "SMAPI.ConsoleCommands", "EntryDll": "ConsoleCommands.dll" diff --git a/src/SMAPI.Mods.ConsoleCommands/packages.config b/src/SMAPI.Mods.ConsoleCommands/packages.config index a0f76c34..c8b3ae63 100644 --- a/src/SMAPI.Mods.ConsoleCommands/packages.config +++ b/src/SMAPI.Mods.ConsoleCommands/packages.config @@ -1,4 +1,4 @@ <?xml version="1.0" encoding="utf-8"?> <packages> - <package id="Newtonsoft.Json" version="11.0.1-beta3" targetFramework="net45" /> + <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> </packages>
\ No newline at end of file diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 5d45118f..0464e50a 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -3,6 +3,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; +using StardewModdingAPI.Common; using StardewModdingAPI.Web.Framework.Clients.GitHub; using StardewModdingAPI.Web.ViewModels; @@ -23,7 +24,10 @@ namespace StardewModdingAPI.Web.Controllers private readonly IGitHubClient GitHub; /// <summary>The cache time for release info.</summary> - private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(5); + private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1); + + /// <summary>The GitHub repository name to check for update.</summary> + private readonly string RepositoryName = "Pathoschild/SMAPI"; /********* @@ -42,17 +46,24 @@ namespace StardewModdingAPI.Web.Controllers [HttpGet] public async Task<ViewResult> Index() { - // fetch latest SMAPI release - GitRelease release = await this.Cache.GetOrCreateAsync("latest-smapi-release", async entry => + // fetch SMAPI releases + IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry => + { + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); + GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false); + return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release)); + }); + IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry => { entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime); - return await this.GitHub.GetLatestReleaseAsync("Pathoschild/SMAPI"); + GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true); + return release.IsPrerelease + ? this.GetBetaDownload(release) + : null; }); - string downloadUrl = this.GetMainDownloadUrl(release); - string devDownloadUrl = this.GetDevDownloadUrl(release); // render view - var model = new IndexModel(release.Name, release.Body, downloadUrl, devDownloadUrl); + var model = new IndexModel(stableVersion, betaVersion); return this.View(model); } @@ -89,5 +100,33 @@ namespace StardewModdingAPI.Web.Controllers // fallback just in case return "https://github.com/pathoschild/SMAPI/releases"; } + + /// <summary>Get the latest beta download for a SMAPI release.</summary> + /// <param name="release">The SMAPI release.</param> + private IndexVersionModel GetBetaDownload(GitRelease release) + { + // get download with the latest version + SemanticVersionImpl latestVersion = null; + string latestUrl = null; + foreach (GitAsset asset in release.Assets ?? new GitAsset[0]) + { + // parse version + Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip"); + if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version)) + continue; + + // save latest version + if (latestVersion == null || latestVersion.CompareTo(version) < 0) + { + latestVersion = version; + latestUrl = asset.DownloadUrl; + } + } + + // return if prerelease + return latestVersion?.Tag != null + ? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null) + : null; + } } } diff --git a/src/SMAPI.Web/Controllers/ModsApiController.cs b/src/SMAPI.Web/Controllers/ModsApiController.cs index abae7db7..24517263 100644 --- a/src/SMAPI.Web/Controllers/ModsApiController.cs +++ b/src/SMAPI.Web/Controllers/ModsApiController.cs @@ -29,8 +29,11 @@ namespace StardewModdingAPI.Web.Controllers /// <summary>The cache in which to store mod metadata.</summary> private readonly IMemoryCache Cache; - /// <summary>The number of minutes update checks should be cached before refetching them.</summary> - private readonly int CacheMinutes; + /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> + private readonly int SuccessCacheMinutes; + + /// <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; @@ -50,7 +53,8 @@ namespace StardewModdingAPI.Web.Controllers ModUpdateCheckConfig config = configProvider.Value; this.Cache = cache; - this.CacheMinutes = config.CacheMinutes; + this.SuccessCacheMinutes = config.SuccessCacheMinutes; + this.ErrorCacheMinutes = config.ErrorCacheMinutes; this.VersionRegex = config.SemanticVersionRegex; this.Repositories = new IModRepository[] @@ -115,13 +119,13 @@ namespace StardewModdingAPI.Web.Controllers if (info.Error == null) { if (info.Version == null) - info = new ModInfoModel(info.Name, info.Version, info.Url, "Mod has no version number."); + info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number."); if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)) - info = new ModInfoModel(info.Name, info.Version, info.Url, $"Mod has invalid semantic version '{info.Version}'."); + info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'."); } // cache & return - entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.CacheMinutes); + entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes); return info; }); } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs index 0b205660..4abe0737 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Net; using System.Threading.Tasks; using Pathoschild.Http.Client; @@ -11,8 +12,11 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /********* ** Properties *********/ - /// <summary>The URL for a GitHub releases API query excluding the base URL, where {0} is the repository owner and name.</summary> - private readonly string ReleaseUrlFormat; + /// <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; @@ -23,14 +27,16 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>Construct an instance.</summary> /// <param name="baseUrl">The base URL for the GitHub API.</param> - /// <param name="releaseUrlFormat">The URL for a GitHub releases API query excluding the <paramref name="baseUrl"/>, where {0} is the repository owner and name.</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 releaseUrlFormat, string userAgent, string acceptHeader, string username, string password) + public GitHubClient(string baseUrl, string stableReleaseUrlFormat, string anyReleaseUrlFormat, string userAgent, string acceptHeader, string username, string password) { - this.ReleaseUrlFormat = releaseUrlFormat; + this.StableReleaseUrlFormat = stableReleaseUrlFormat; + this.AnyReleaseUrlFormat = anyReleaseUrlFormat; this.Client = new FluentClient(baseUrl) .SetUserAgent(userAgent) @@ -41,18 +47,23 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> - /// <returns>Returns the latest release if found, else <c>null</c>.</returns> - public async Task<GitRelease> GetLatestReleaseAsync(string repo) + /// <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) { - // validate key format - if (!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)); - - // fetch info + this.AssetKeyFormat(repo); try { + if (includePrerelease) + { + GitRelease[] results = await this.Client + .GetAsync(string.Format(this.AnyReleaseUrlFormat, repo)) + .AsArray<GitRelease>(); + return results.FirstOrDefault(); + } + return await this.Client - .GetAsync(string.Format(this.ReleaseUrlFormat, repo)) + .GetAsync(string.Format(this.StableReleaseUrlFormat, repo)) .As<GitRelease>(); } catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound) @@ -66,5 +77,18 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub { this.Client?.Dispose(); } + + + /********* + ** Private methods + *********/ + /// <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) + { + 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/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs index b944088d..827374fb 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs @@ -19,6 +19,10 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub /// <summary>The Markdown description for the release.</summary> public string Body { get; set; } + /// <summary>Whether this is a prerelease version.</summary> + [JsonProperty("prerelease")] + public bool IsPrerelease { get; set; } + /// <summary>The attached files.</summary> public GitAsset[] Assets { get; set; } } diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs index 6e8eadff..9519c26f 100644 --- a/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs +++ b/src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs @@ -11,7 +11,8 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub *********/ /// <summary>Get the latest release for a GitHub repository.</summary> /// <param name="repo">The repository key (like <c>Pathoschild/SMAPI</c>).</param> - /// <returns>Returns the latest release if found, else <c>null</c>.</returns> - Task<GitRelease> GetLatestReleaseAsync(string repo); + /// <param name="includePrerelease">Whether to return a prerelease version if it's latest.</param> + /// <returns>Returns the release if found, else <c>null</c>.</returns> + Task<GitRelease> GetLatestReleaseAsync(string repo, bool includePrerelease = false); } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 61219414..de6c024a 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -29,8 +29,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// <summary>The base URL for the GitHub API.</summary> public string GitHubBaseUrl { get; set; } - /// <summary>The URL for a GitHub API latest-release query excluding the <see cref="GitHubBaseUrl"/>, where {0} is the organisation and project name.</summary> - public string GitHubReleaseUrlFormat { 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; } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs index 58c3a100..fc3b7dc2 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs @@ -6,8 +6,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /********* ** Accessors *********/ - /// <summary>The number of minutes update checks should be cached before refetching them.</summary> - public int CacheMinutes { get; set; } + /// <summary>The number of minutes successful update checks should be cached before refetching them.</summary> + public int SuccessCacheMinutes { get; set; } + + /// <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> diff --git a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs index 266055a6..3e5a4272 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories return new ModInfoModel("Found no mod with this ID."); // create model - return new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url); + return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url); } catch (Exception ex) { diff --git a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs index 7bad6127..59eb8cd1 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs @@ -38,10 +38,21 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories // fetch info try { - GitRelease release = await this.Client.GetLatestReleaseAsync(id); - return release != null - ? new ModInfoModel(id, this.NormaliseVersion(release.Tag), $"https://github.com/{id}/releases") - : new ModInfoModel("Found no mod with this ID."); + // get latest release + GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true); + GitRelease preview = null; + if (latest == null) + return new ModInfoModel("Found no mod with this ID."); + + // get latest stable release (if not latest) + if (latest.IsPrerelease) + { + preview = latest; + latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false); + } + + // return data + return new ModInfoModel(name: id, version: this.NormaliseVersion(latest?.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases"); } catch (Exception ex) { diff --git a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs index e1dc0fcc..6411ad4c 100644 --- a/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs +++ b/src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs @@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories return new ModInfoModel("Found no mod with this ID."); if (mod.Error != null) return new ModInfoModel(mod.Error); - return new ModInfoModel(mod.Name, this.NormaliseVersion(mod.Version), mod.Url); + return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url); } catch (Exception ex) { diff --git a/src/SMAPI.Web/StardewModdingAPI.Web.csproj b/src/SMAPI.Web/StardewModdingAPI.Web.csproj index 19198503..e2eee8a8 100644 --- a/src/SMAPI.Web/StardewModdingAPI.Web.csproj +++ b/src/SMAPI.Web/StardewModdingAPI.Web.csproj @@ -10,13 +10,13 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="HtmlAgilityPack" Version="1.6.0" /> - <PackageReference Include="Markdig" Version="0.14.8" /> - <PackageReference Include="Microsoft.AspNetCore" Version="2.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.0" /> - <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.0" /> - <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.0" /> + <PackageReference Include="HtmlAgilityPack" Version="1.7.2" /> + <PackageReference Include="Markdig" Version="0.14.9" /> + <PackageReference Include="Microsoft.AspNetCore" Version="2.0.2" /> + <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.3" /> + <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.2" /> + <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.2" /> + <PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.1" /> <PackageReference Include="Pathoschild.Http.FluentClient" Version="3.1.0" /> </ItemGroup> <ItemGroup> diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index d7d4d074..47102e5c 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -74,7 +74,8 @@ namespace StardewModdingAPI.Web services.AddSingleton<IGitHubClient>(new GitHubClient( baseUrl: api.GitHubBaseUrl, - releaseUrlFormat: api.GitHubReleaseUrlFormat, + stableReleaseUrlFormat: api.GitHubStableReleaseUrlFormat, + anyReleaseUrlFormat: api.GitHubAnyReleaseUrlFormat, userAgent: userAgent, acceptHeader: api.GitHubAcceptHeader, username: api.GitHubUsername, diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs index 6d3da91e..4268c878 100644 --- a/src/SMAPI.Web/ViewModels/IndexModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -6,17 +6,11 @@ namespace StardewModdingAPI.Web.ViewModels /********* ** Accessors *********/ - /// <summary>The latest SMAPI version.</summary> - public string LatestVersion { get; set; } + /// <summary>The latest stable SMAPI version.</summary> + public IndexVersionModel StableVersion { get; set; } - /// <summary>The Markdown description for the release.</summary> - public string Description { get; set; } - - /// <summary>The main download URL.</summary> - public string DownloadUrl { get; set; } - - /// <summary>The for-developers download URL.</summary> - public string DevDownloadUrl { get; set; } + /// <summary>The latest prerelease SMAPI version (if newer than <see cref="StableVersion"/>).</summary> + public IndexVersionModel BetaVersion { get; set; } /********* @@ -26,16 +20,12 @@ namespace StardewModdingAPI.Web.ViewModels public IndexModel() { } /// <summary>Construct an instance.</summary> - /// <param name="latestVersion">The latest SMAPI version.</param> - /// <param name="description">The Markdown description for the release.</param> - /// <param name="downloadUrl">The main download URL.</param> - /// <param name="devDownloadUrl">The for-developers download URL.</param> - internal IndexModel(string latestVersion, string description, string downloadUrl, string devDownloadUrl) + /// <param name="stableVersion">The latest stable SMAPI version.</param> + /// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param> + internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion) { - this.LatestVersion = latestVersion; - this.Description = description; - this.DownloadUrl = downloadUrl; - this.DevDownloadUrl = devDownloadUrl; + this.StableVersion = stableVersion; + this.BetaVersion = betaVersion; } } } diff --git a/src/SMAPI.Web/ViewModels/IndexVersionModel.cs b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs new file mode 100644 index 00000000..4f63b979 --- /dev/null +++ b/src/SMAPI.Web/ViewModels/IndexVersionModel.cs @@ -0,0 +1,41 @@ +namespace StardewModdingAPI.Web.ViewModels +{ + /// <summary>The fields for a SMAPI version.</summary> + public class IndexVersionModel + { + /********* + ** Accessors + *********/ + /// <summary>The release version.</summary> + public string Version { get; set; } + + /// <summary>The Markdown description for the release.</summary> + public string Description { get; set; } + + /// <summary>The main download URL.</summary> + public string DownloadUrl { get; set; } + + /// <summary>The for-developers download URL (not applicable for prerelease versions).</summary> + public string DevDownloadUrl { get; set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + public IndexVersionModel() { } + + /// <summary>Construct an instance.</summary> + /// <param name="version">The release number.</param> + /// <param name="description">The Markdown description for the release.</param> + /// <param name="downloadUrl">The main download URL.</param> + /// <param name="devDownloadUrl">The for-developers download URL (not applicable for prerelease versions).</param> + internal IndexVersionModel(string version, string description, string downloadUrl, string devDownloadUrl) + { + this.Version = version; + this.Description = description; + this.DownloadUrl = downloadUrl; + this.DevDownloadUrl = devDownloadUrl; + } + } +} diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index ad58898e..4efb9f8a 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -13,7 +13,11 @@ </p> <div id="call-to-action"> - <a href="@Model.DownloadUrl" class="main-cta">Download SMAPI @Model.LatestVersion</a><br /> + <a href="@Model.StableVersion.DownloadUrl" class="main-cta">Download SMAPI @Model.StableVersion.Version</a><br /> + @if (Model.BetaVersion != null) + { + <a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta">Download SMAPI @Model.BetaVersion.Version<br /><small>for Stardew Valley 1.3 beta</small></a><br /> + } <a href="https://stardewvalleywiki.com/Modding:Installing_SMAPI" class="secondary-cta">Install guide</a><br /> <a href="https://stardewvalleywiki.com/Modding:Player_FAQs" class="secondary-cta">FAQs</a><br /> <img src="favicon.ico" /> @@ -25,12 +29,29 @@ <li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li> </ul> -<h2>What's new in SMAPI @Model.LatestVersion?</h2> -<div class="github-description"> - @Html.Raw(Markdig.Markdown.ToHtml(Model.Description)) -</div> +@if (Model.BetaVersion == null) +{ + <h2>What's new in SMAPI @Model.StableVersion.Version?</h2> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> +} +else +{ + <h2>What's new in...</h2> + <h3>SMAPI @Model.StableVersion.Version?</h3> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> -<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> + <h3>SMAPI @Model.BetaVersion.Version?</h3> + <div class="github-description"> + @Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description)) + </div> + <p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p> +} <h2>Donate to support SMAPI ♥</h2> <p> @@ -62,7 +83,7 @@ <h2>For mod creators</h2> <ul> - <li><a href="@Model.DevDownloadUrl">SMAPI @Model.LatestVersion 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="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.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> </ul> diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml index d2d8004e..7213e286 100644 --- a/src/SMAPI.Web/Views/LogParser/Index.cshtml +++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml @@ -54,19 +54,19 @@ <caption>Game info:</caption> <tr> <td>SMAPI version:</td> - <td>@Model.ParsedLog.ApiVersion</td> + <td v-pre>@Model.ParsedLog.ApiVersion</td> </tr> <tr> <td>Game version:</td> - <td>@Model.ParsedLog.GameVersion</td> + <td v-pre>@Model.ParsedLog.GameVersion</td> </tr> <tr> <td>Platform:</td> - <td>@Model.ParsedLog.OperatingSystem</td> + <td v-pre>@Model.ParsedLog.OperatingSystem</td> </tr> <tr> <td>Mods path:</td> - <td>@Model.ParsedLog.ModPath</td> + <td v-pre>@Model.ParsedLog.ModPath</td> </tr> <tr> <td>Log started:</td> @@ -85,7 +85,7 @@ { <tr v-on:click="toggleMod('@GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@GetSlug(mod.Name)'] }"> <td><input type="checkbox" v-bind:checked="showMods['@GetSlug(mod.Name)']" v-show="anyModsHidden" /></td> - <td> + <td v-pre> @mod.Name @if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList)) { @@ -97,19 +97,19 @@ </div> } </td> - <td>@mod.Version</td> - <td>@mod.Author</td> + <td v-pre>@mod.Version</td> + <td v-pre>@mod.Author</td> @if (mod.Errors == 0) { - <td class="color-green">no errors</td> + <td v-pre class="color-green">no errors</td> } else if (mod.Errors == 1) { - <td class="color-red">@mod.Errors error</td> + <td v-pre class="color-red">@mod.Errors error</td> } else { - <td class="color-red">@mod.Errors errors</td> + <td v-pre class="color-red">@mod.Errors errors</td> } </tr> } @@ -130,16 +130,16 @@ string levelStr = message.Level.ToString().ToLower(); <tr class="@levelStr mod" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')"> - <td>@message.Time</td> - <td>@message.Level.ToString().ToUpper()</td> - <td data-title="@message.Mod">@message.Mod</td> - <td>@message.Text</td> + <td v-pre>@message.Time</td> + <td v-pre>@message.Level.ToString().ToUpper()</td> + <td v-pre data-title="@message.Mod">@message.Mod</td> + <td v-pre>@message.Text</td> </tr> if (message.Repeated > 0) { <tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')"> <td colspan="3"></td> - <td><i>repeats [@message.Repeated] times.</i></td> + <td v-pre><i>repeats [@message.Repeated] times.</i></td> </tr> } } @@ -151,11 +151,11 @@ else if (Model.ParsedLog?.IsValid == false) <h2>Parsed log</h2> <div id="error" class="color-red"> <p><strong>We couldn't parse that file, but you can still share the link.</strong></p> - <p>Error details: @Model.ParsedLog.Error</p> + <p v-pre>Error details: @Model.ParsedLog.Error</p> </div> <h3>Raw log</h3> - <pre>@Model.ParsedLog.RawText</pre> + <pre v-pre>@Model.ParsedLog.RawText</pre> } <div id="upload-area"> diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index 3cf72ddb..03ca31ed 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -24,7 +24,8 @@ "ChucklefishModPageUrlFormat": "resources/{0}", "GitHubBaseUrl": "https://api.github.com", - "GitHubReleaseUrlFormat": "repos/{0}/releases/latest", + "GitHubStableReleaseUrlFormat": "repos/{0}/releases/latest", + "GitHubAnyReleaseUrlFormat": "repos/{0}/releases?per_page=1", "GitHubAcceptHeader": "application/vnd.github.v3+json", "GitHubUsername": null, // see top note "GitHubPassword": null, // see top note @@ -39,7 +40,8 @@ }, "ModUpdateCheck": { - "CacheMinutes": 60, + "SuccessCacheMinutes": 60, + "ErrorCacheMinutes": 5, "SemanticVersionRegex": "^(?>(?<major>0|[1-9]\\d*))\\.(?>(?<minor>0|[1-9]\\d*))(?>(?:\\.(?<patch>0|[1-9]\\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\\-\\.]?)+))?$", "ChucklefishKey": "Chucklefish", diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs index d91fa5fb..6270186a 100644 --- a/src/SMAPI/Constants.cs +++ b/src/SMAPI/Constants.cs @@ -41,7 +41,7 @@ namespace StardewModdingAPI #if STARDEW_VALLEY_1_3 new SemanticVersion($"2.6-alpha.{DateTime.UtcNow:yyyyMMddHHmm}"); #else - new SemanticVersion($"2.5.3"); + new SemanticVersion("2.5.4"); #endif /// <summary>The minimum supported version of Stardew Valley.</summary> @@ -49,7 +49,7 @@ namespace StardewModdingAPI #if STARDEW_VALLEY_1_3 new GameVersion("1.3.0.4"); #else - new SemanticVersion("1.2.33"); + new SemanticVersion("1.2.30"); #endif /// <summary>The maximum supported version of Stardew Valley.</summary> @@ -81,8 +81,8 @@ namespace StardewModdingAPI /**** ** Internal ****/ - /// <summary>The GitHub repository to check for updates.</summary> - internal const string GitHubRepository = "Pathoschild/SMAPI"; + /// <summary>The URL of the SMAPI home page.</summary> + internal const string HomePageUrl = "https://smapi.io"; /// <summary>The file path for the SMAPI configuration file.</summary> internal static string ApiConfigPath => Path.Combine(Constants.ExecutionPath, $"{typeof(Program).Assembly.GetName().Name}.config.json"); diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs index c665484f..1eef2afb 100644 --- a/src/SMAPI/Framework/Content/AssetDataForImage.cs +++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs @@ -58,7 +58,7 @@ namespace StardewModdingAPI.Framework.Content for (int i = 0; i < sourceData.Length; i++) { Color pixel = sourceData[i]; - if (pixel.A != 0) // not transparent + if (pixel.A > 4) // not transparent (note: on Linux/Mac, fully transparent pixels may have an alpha up to 4 for some reason) newData[i] = pixel; } sourceData = newData; diff --git a/src/SMAPI/Framework/ContentCore.cs b/src/SMAPI/Framework/ContentCore.cs index 85b8db8f..3c7e7b5a 100644 --- a/src/SMAPI/Framework/ContentCore.cs +++ b/src/SMAPI/Framework/ContentCore.cs @@ -51,7 +51,7 @@ namespace StardewModdingAPI.Framework private readonly IDictionary<string, LocalizedContentManager.LanguageCode> LanguageCodes; /// <summary>Provides metadata for core game assets.</summary> - private readonly CoreAssets CoreAssets; + private readonly CoreAssetPropagator CoreAssets; /// <summary>The assets currently being intercepted by <see cref="IAssetLoader"/> instances. This is used to prevent infinite loops when a loader loads a new asset.</summary> private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>(); @@ -103,7 +103,7 @@ namespace StardewModdingAPI.Framework this.ModContentPrefix = this.GetAssetNameFromFilePath(Constants.ModPath); // get asset data - this.CoreAssets = new CoreAssets(this.NormaliseAssetName, reflection); + this.CoreAssets = new CoreAssetPropagator(this.NormaliseAssetName, reflection); this.Locales = this.GetKeyLocales(reflection); this.LanguageCodes = this.Locales.ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); } @@ -368,7 +368,7 @@ namespace StardewModdingAPI.Framework int reloaded = 0; foreach (string key in removeAssetNames) { - if (this.CoreAssets.ReloadForKey(Game1.content, key)) // use an intercepted content manager + if (this.CoreAssets.Propagate(Game1.content, key)) // use an intercepted content manager reloaded++; } diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs new file mode 100644 index 00000000..e54e0286 --- /dev/null +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -0,0 +1,642 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Xna.Framework.Graphics; +using StardewModdingAPI.Framework.Reflection; +using StardewValley; +using StardewValley.BellsAndWhistles; +using StardewValley.Buildings; +using StardewValley.Characters; +using StardewValley.Locations; +using StardewValley.Menus; +using StardewValley.Objects; +using StardewValley.Projectiles; +using StardewValley.TerrainFeatures; + +namespace StardewModdingAPI.Metadata +{ + /// <summary>Propagates changes to core assets to the game state.</summary> + internal class CoreAssetPropagator + { + /********* + ** Properties + *********/ + /// <summary>Normalises an asset key to match the cache key.</summary> + private readonly Func<string, string> GetNormalisedPath; + + /// <summary>Simplifies access to private game code.</summary> + private readonly Reflector Reflection; + + + /********* + ** Public methods + *********/ + /// <summary>Initialise the core asset data.</summary> + /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> + /// <param name="reflection">Simplifies access to private code.</param> + public CoreAssetPropagator(Func<string, string> getNormalisedPath, Reflector reflection) + { + this.GetNormalisedPath = getNormalisedPath; + this.Reflection = reflection; + } + + /// <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> + /// <returns>Returns whether an asset was reloaded.</returns> + public bool Propagate(LocalizedContentManager content, string key) + { + object result = this.PropagateImpl(content, key); + if (result is bool b) + return b; + return result != null; + } + + + /********* + ** Private methods + *********/ + /// <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> + /// <returns>Returns any non-null value to indicate an asset was loaded.</returns> + private object PropagateImpl(LocalizedContentManager content, string key) + { + Reflector reflection = this.Reflection; + switch (key.ToLower().Replace("/", "\\")) // normalised 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); + + /**** + ** Buildings + ****/ + case "buildings\\houses": // Farm +#if STARDEW_VALLEY_1_3 + reflection.GetField<Texture2D>(typeof(Farm), nameof(Farm.houseTextures)).SetValue(content.Load<Texture2D>(key)); + return true; +#else + { + Farm farm = Game1.getFarm(); + if (farm == null) + return false; + return farm.houseTextures = content.Load<Texture2D>(key); + } +#endif + + /**** + ** Content\Characters\Farmer + ****/ + case "characters\\farmer\\accessories": // Game1.loadContent + return FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key); + + case "characters\\farmer\\farmer_base": // Farmer + if (Game1.player == null || !Game1.player.isMale) + return false; +#if STARDEW_VALLEY_1_3 + return Game1.player.FarmerRenderer = new FarmerRenderer(key); +#else + return Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); +#endif + + case "characters\\farmer\\farmer_girl_base": // Farmer + if (Game1.player == null || Game1.player.isMale) + return false; +#if STARDEW_VALLEY_1_3 + return Game1.player.FarmerRenderer = new FarmerRenderer(key); +#else + return Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); +#endif + + case "characters\\farmer\\hairstyles": // Game1.loadContent + return FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key); + + case "characters\\farmer\\hats": // Game1.loadContent + return FarmerRenderer.hatsTexture = content.Load<Texture2D>(key); + + case "characters\\farmer\\shirts": // Game1.loadContent + return FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key); + + /**** + ** Content\Data + ****/ + case "data\\achievements": // Game1.loadContent + return Game1.achievements = content.Load<Dictionary<int, string>>(key); + + case "data\\bigcraftablesinformation": // Game1.loadContent + return Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key); + + case "data\\cookingrecipes": // CraftingRecipe.InitShared + return CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key); + + case "data\\craftingrecipes": // CraftingRecipe.InitShared + return CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key); + + case "data\\npcgifttastes": // Game1.loadContent + return Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key); + + case "data\\objectinformation": // Game1.loadContent + return Game1.objectInformation = content.Load<Dictionary<int, string>>(key); + + /**** + ** Content\Fonts + ****/ + case "fonts\\spritefont1": // Game1.loadContent + return Game1.dialogueFont = content.Load<SpriteFont>(key); + + case "fonts\\smallfont": // Game1.loadContent + return Game1.smallFont = content.Load<SpriteFont>(key); + + case "fonts\\tinyfont": // Game1.loadContent + return Game1.tinyFont = content.Load<SpriteFont>(key); + + case "fonts\\tinyfontborder": // Game1.loadContent + return Game1.tinyFontBorder = content.Load<SpriteFont>(key); + + /**** + ** Content\Lighting + ****/ + case "loosesprites\\lighting\\greenlight": // Game1.loadContent + return Game1.cauldronLight = content.Load<Texture2D>(key); + + case "loosesprites\\lighting\\indoorwindowlight": // Game1.loadContent + return Game1.indoorWindowLight = content.Load<Texture2D>(key); + + case "loosesprites\\lighting\\lantern": // Game1.loadContent + return Game1.lantern = content.Load<Texture2D>(key); + + case "loosesprites\\lighting\\sconcelight": // Game1.loadContent + return Game1.sconceLight = content.Load<Texture2D>(key); + + case "loosesprites\\lighting\\windowlight": // Game1.loadContent + return Game1.windowLight = content.Load<Texture2D>(key); + + /**** + ** Content\LooseSprites + ****/ + case "loosesprites\\controllermaps": // Game1.loadContent + return Game1.controllerMaps = content.Load<Texture2D>(key); + + case "loosesprites\\cursors": // Game1.loadContent + return Game1.mouseCursors = content.Load<Texture2D>(key); + + case "loosesprites\\daybg": // Game1.loadContent + return Game1.daybg = content.Load<Texture2D>(key); + + case "loosesprites\\font_bold": // Game1.loadContent + return SpriteText.spriteTexture = content.Load<Texture2D>(key); + + case "loosesprites\\font_colored": // Game1.loadContent + return SpriteText.coloredTexture = content.Load<Texture2D>(key); + + case "loosesprites\\nightbg": // Game1.loadContent + return Game1.nightbg = content.Load<Texture2D>(key); + + case "loosesprites\\shadow": // Game1.loadContent + return Game1.shadowTexture = content.Load<Texture2D>(key); + + /**** + ** Content\Critters + ****/ +#if !STARDEW_VALLEY_1_3 + case "tilesheets\\critters": // Criter.InitShared + return Critter.critterTexture = content.Load<Texture2D>(key); +#endif + + case "tilesheets\\crops": // Game1.loadContent + return Game1.cropSpriteSheet = content.Load<Texture2D>(key); + + case "tilesheets\\debris": // Game1.loadContent + return Game1.debrisSpriteSheet = content.Load<Texture2D>(key); + + case "tilesheets\\emotes": // Game1.loadContent + return Game1.emoteSpriteSheet = content.Load<Texture2D>(key); + + case "tilesheets\\furniture": // Game1.loadContent + return Furniture.furnitureTexture = content.Load<Texture2D>(key); + + case "tilesheets\\projectiles": // Game1.loadContent + return Projectile.projectileSheet = content.Load<Texture2D>(key); + + case "tilesheets\\rain": // Game1.loadContent + return Game1.rainTexture = content.Load<Texture2D>(key); + + case "tilesheets\\tools": // Game1.ResetToolSpriteSheet + Game1.ResetToolSpriteSheet(); + return true; + + case "tilesheets\\weapons": // Game1.loadContent + return Tool.weaponsTexture = content.Load<Texture2D>(key); + + /**** + ** Content\Maps + ****/ + case "maps\\menutiles": // Game1.loadContent + return Game1.menuTexture = content.Load<Texture2D>(key); + + case "maps\\springobjects": // Game1.loadContent + return Game1.objectSpriteSheet = content.Load<Texture2D>(key); + + case "maps\\walls_and_floors": // Wallpaper + return Wallpaper.wallpaperTexture = content.Load<Texture2D>(key); + + /**** + ** Content\Minigames + ****/ + case "minigames\\clouds": // TitleMenu + if (Game1.activeClickableMenu is TitleMenu) + { + reflection.GetField<Texture2D>(Game1.activeClickableMenu, "cloudsTexture").SetValue(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()) +#if STARDEW_VALLEY_1_3 + bird.texture = texture; +#else + bird.Texture = texture; +#endif + return true; + } + return false; + + /**** + ** Content\TileSheets + ****/ + case "tilesheets\\animations": // Game1.loadContent + return Game1.animations = content.Load<Texture2D>(key); + + case "tilesheets\\buffsicons": // Game1.loadContent + return Game1.buffsIcons = content.Load<Texture2D>(key); + + case "tilesheets\\bushes": // new Bush() +#if STARDEW_VALLEY_1_3 + reflection.GetField<Lazy<Texture2D>>(typeof(Bush), "texture").SetValue(new Lazy<Texture2D>(() => content.Load<Texture2D>(key))); + return true; +#else + return Bush.texture = content.Load<Texture2D>(key); +#endif + + case "tilesheets\\craftables": // Game1.loadContent + return Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key); + + case "tilesheets\\fruittrees": // FruitTree + return FruitTree.texture = content.Load<Texture2D>(key); + + /**** + ** Content\TerrainFeatures + ****/ + case "terrainfeatures\\flooring": // Flooring + return Flooring.floorsTexture = content.Load<Texture2D>(key); + + case "terrainfeatures\\hoedirt": // from HoeDirt + return HoeDirt.lightTexture = content.Load<Texture2D>(key); + + case "terrainfeatures\\hoedirtdark": // from HoeDirt + return HoeDirt.darkTexture = content.Load<Texture2D>(key); + + case "terrainfeatures\\hoedirtsnow": // from HoeDirt + return HoeDirt.snowTexture = content.Load<Texture2D>(key); + + case "terrainfeatures\\mushroom_tree": // from Tree + return this.ReloadTreeTextures(content, key, Tree.mushroomTree); + + case "terrainfeatures\\tree_palm": // from Tree + return this.ReloadTreeTextures(content, key, Tree.palmTree); + + case "terrainfeatures\\tree1_fall": // from Tree + case "terrainfeatures\\tree1_spring": // from Tree + case "terrainfeatures\\tree1_summer": // from Tree + case "terrainfeatures\\tree1_winter": // from Tree + return this.ReloadTreeTextures(content, key, Tree.bushyTree); + + case "terrainfeatures\\tree2_fall": // from Tree + case "terrainfeatures\\tree2_spring": // from Tree + case "terrainfeatures\\tree2_summer": // from Tree + case "terrainfeatures\\tree2_winter": // from Tree + return this.ReloadTreeTextures(content, key, Tree.leafyTree); + + case "terrainfeatures\\tree3_fall": // from Tree + case "terrainfeatures\\tree3_spring": // from Tree + case "terrainfeatures\\tree3_winter": // from Tree + return this.ReloadTreeTextures(content, key, Tree.pineTree); + } + + // dynamic textures + 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")) + return this.ReloadNpcSprites(content, key, monster: false); + + if (this.IsInFolder(key, "Characters\\Monsters")) + return this.ReloadNpcSprites(content, key, monster: true); + + if (key.StartsWith(this.GetNormalisedPath("LooseSprites\\Fence"), StringComparison.InvariantCultureIgnoreCase)) + return this.ReloadFenceTextures(content, key); + + if (this.IsInFolder(key, "Portraits")) + return this.ReloadNpcPortraits(content, key); + + return false; + } + + + /********* + ** Private methods + *********/ + /**** + ** Reload methods + ****/ + /// <summary>Reload the sprites for matching pets or horses.</summary> + /// <typeparam name="TAnimal">The animal type.</typeparam> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether any textures were reloaded.</returns> + private bool ReloadPetOrHorseSprites<TAnimal>(LocalizedContentManager content, string key) + where TAnimal : NPC + { + // find matches + TAnimal[] animals = this.GetCharacters().OfType<TAnimal>().ToArray(); + if (!animals.Any()) + return false; + + // update sprites + Texture2D texture = content.Load<Texture2D>(key); + foreach (TAnimal animal in animals) + this.SetSpriteTexture(animal.sprite, texture); + return true; + } + + /// <summary>Reload the sprites for matching farm animals.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether any textures were reloaded.</returns> + /// <remarks>Derived from <see cref="FarmAnimal.reload"/>.</remarks> + private bool ReloadFarmAnimalSprites(LocalizedContentManager content, string key) + { + // find matches + FarmAnimal[] animals = this.GetFarmAnimals().ToArray(); + if (!animals.Any()) + return false; + + // update sprites + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + foreach (FarmAnimal animal in animals) + { + // get expected key + string expectedKey = animal.age < animal.ageWhenMature + ? $"Baby{(animal.type == "Duck" ? "White Chicken" : animal.type)}" + : animal.type; + if (animal.showDifferentTextureWhenReadyForHarvest && animal.currentProduce <= 0) + expectedKey = $"Sheared{expectedKey}"; + expectedKey = $"Animals\\{expectedKey}"; + + // reload asset + if (expectedKey == key) + this.SetSpriteTexture(animal.sprite, texture.Value); + } + return texture.IsValueCreated; + } + + /// <summary>Reload building 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 whether any textures were reloaded.</returns> + private bool ReloadBuildings(LocalizedContentManager content, string key) + { + // get buildings + string type = Path.GetFileName(key); + Building[] buildings = Game1.locations + .OfType<BuildableGameLocation>() + .SelectMany(p => p.buildings) + .Where(p => p.buildingType == type) + .ToArray(); + + // reload buildings + if (buildings.Any()) + { + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + foreach (Building building in buildings) +#if STARDEW_VALLEY_1_3 + building.texture = texture; +#else + building.texture = texture.Value; +#endif + return true; + } + return false; + } + + /// <summary>Reload the sprites for a fence type.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <returns>Returns whether any textures were reloaded.</returns> + private bool ReloadFenceTextures(LocalizedContentManager content, string key) + { + // get fence type + if (!int.TryParse(this.GetSegments(key)[1].Substring("Fence".Length), out int fenceType)) + return false; + + // get fences + Fence[] fences = + ( + from location in this.GetLocations() + from fence in location.Objects.Values.OfType<Fence>() + where fenceType == 1 + ? fence.isGate + : fence.whichType == fenceType + select fence + ) + .ToArray(); + + // update fence textures + foreach (Fence fence in fences) + fence.reloadSprite(); + return true; + } + + /// <summary>Reload the sprites for matching NPCs.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <param name="monster">Whether to match monsters (<c>true</c>) or non-monsters (<c>false</c>).</param> + /// <returns>Returns whether any textures were reloaded.</returns> + private bool ReloadNpcSprites(LocalizedContentManager content, string key, bool monster) + { + // get NPCs + string name = this.GetNpcNameFromFileName(Path.GetFileName(key)); + NPC[] characters = this.GetCharacters().Where(npc => npc.name == name && npc.IsMonster == monster).ToArray(); + if (!characters.Any()) + return false; + + // update portrait + Texture2D texture = content.Load<Texture2D>(key); + foreach (NPC character in characters) + this.SetSpriteTexture(character.Sprite, texture); + return true; + } + + /// <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) + { + // get NPCs + string name = this.GetNpcNameFromFileName(Path.GetFileName(key)); + NPC[] villagers = this.GetCharacters().Where(npc => npc.name == name && npc.isVillager()).ToArray(); + if (!villagers.Any()) + return false; + + // update portrait + Texture2D texture = content.Load<Texture2D>(key); + foreach (NPC villager in villagers) + villager.Portrait = texture; + return true; + } + + /// <summary>Reload tree textures.</summary> + /// <param name="content">The content manager through which to reload the asset.</param> + /// <param name="key">The asset key to reload.</param> + /// <param name="type">The type to reload.</param> + /// <returns>Returns whether any textures were reloaded.</returns> + private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) + { + Tree[] trees = Game1.locations + .SelectMany(p => p.terrainFeatures.Values.OfType<Tree>()) + .Where(tree => tree.treeType == type) + .ToArray(); + + if (trees.Any()) + { + Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); + foreach (Tree tree in trees) +#if STARDEW_VALLEY_1_3 + this.Reflection.GetField<Lazy<Texture2D>>(tree, "texture").SetValue(texture); +#else + this.Reflection.GetField<Texture2D>(tree, "texture").SetValue(texture.Value); +#endif + return true; + } + + return false; + } + + /**** + ** Helpers + ****/ + /// <summary>Reload the texture for an animated sprite.</summary> + /// <param name="sprite">The animated sprite to update.</param> + /// <param name="texture">The texture to set.</param> + private void SetSpriteTexture(AnimatedSprite sprite, Texture2D texture) + { +#if STARDEW_VALLEY_1_3 + this.Reflection.GetField<Texture2D>(sprite, "spriteTexture").SetValue(texture); +#else + sprite.Texture = texture; +#endif + } + + /// <summary>Get an NPC name from the name of their file under <c>Content/Characters</c>.</summary> + /// <param name="name">The file name.</param> + /// <remarks>Derived from <see cref="NPC.reloadSprite"/>.</remarks> + private string GetNpcNameFromFileName(string name) + { + switch (name) + { + case "Mariner": + return "Old Mariner"; + case "DwarfKing": + return "Dwarf King"; + case "MrQi": + return "Mister Qi"; + default: + return name; + } + } + + /// <summary>Get all NPCs in the game (excluding farm animals).</summary> + private IEnumerable<NPC> GetCharacters() + { + return this.GetLocations().SelectMany(p => p.characters); + } + + /// <summary>Get all farm animals in the game.</summary> + private IEnumerable<FarmAnimal> GetFarmAnimals() + { + foreach (GameLocation location in this.GetLocations()) + { + if (location is Farm farm) + { + foreach (FarmAnimal animal in farm.animals.Values) + yield return animal; + } + else if (location is AnimalHouse animalHouse) + foreach (FarmAnimal animal in animalHouse.animals.Values) + yield return animal; + } + } + + /// <summary>Get all locations in the game.</summary> + private IEnumerable<GameLocation> GetLocations() + { + foreach (GameLocation location in Game1.locations) + { + yield return location; + + if (location is BuildableGameLocation buildableLocation) + { + foreach (Building building in buildableLocation.buildings) + { + if (building.indoors != null) + yield return building.indoors; + } + } + } + } + + /// <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> + /// <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) + { + return + key.StartsWith(this.GetNormalisedPath($"{folder}\\"), StringComparison.InvariantCultureIgnoreCase) + && (allowSubfolders || this.CountSegments(key) == this.CountSegments(folder) + 1); + } + + /// <summary>Get the segments in a path (e.g. 'a/b' is 'a' and 'b').</summary> + /// <param name="path">The path to check.</param> + private string[] GetSegments(string path) + { + if (path == null) + return new string[0]; + return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + /// <summary>Count the number of segments in a path (e.g. 'a/b' is 2).</summary> + /// <param name="path">The path to check.</param> + private int CountSegments(string path) + { + return this.GetSegments(path).Length; + } + } +} diff --git a/src/SMAPI/Metadata/CoreAssets.cs b/src/SMAPI/Metadata/CoreAssets.cs deleted file mode 100644 index 87629682..00000000 --- a/src/SMAPI/Metadata/CoreAssets.cs +++ /dev/null @@ -1,220 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Microsoft.Xna.Framework.Graphics; -using StardewModdingAPI.Framework.Reflection; -using StardewValley; -using StardewValley.BellsAndWhistles; -using StardewValley.Buildings; -using StardewValley.Locations; -using StardewValley.Menus; -using StardewValley.Objects; -using StardewValley.Projectiles; -using StardewValley.TerrainFeatures; - -namespace StardewModdingAPI.Metadata -{ - /// <summary>Provides metadata about core assets in the game.</summary> - internal class CoreAssets - { - /********* - ** Properties - *********/ - /// <summary>Normalises an asset key to match the cache key.</summary> - protected readonly Func<string, string> GetNormalisedPath; - - /// <summary>Setters which update static or singleton texture fields indexed by normalised asset key.</summary> - private readonly IDictionary<string, Action<LocalizedContentManager, string>> SingletonSetters; - - - /********* - ** Public methods - *********/ - /// <summary>Initialise the core asset data.</summary> - /// <param name="getNormalisedPath">Normalises an asset key to match the cache key.</param> - /// <param name="reflection">Simplifies access to private code.</param> - public CoreAssets(Func<string, string> getNormalisedPath, Reflector reflection) - { - this.GetNormalisedPath = getNormalisedPath; - this.SingletonSetters = - new Dictionary<string, Action<LocalizedContentManager, string>> - { - // from CraftingRecipe.InitShared - ["Data\\CraftingRecipes"] = (content, key) => CraftingRecipe.craftingRecipes = content.Load<Dictionary<string, string>>(key), - ["Data\\CookingRecipes"] = (content, key) => CraftingRecipe.cookingRecipes = content.Load<Dictionary<string, string>>(key), - - // from Game1.loadContent - ["LooseSprites\\daybg"] = (content, key) => Game1.daybg = content.Load<Texture2D>(key), - ["LooseSprites\\nightbg"] = (content, key) => Game1.nightbg = content.Load<Texture2D>(key), - ["Maps\\MenuTiles"] = (content, key) => Game1.menuTexture = content.Load<Texture2D>(key), - ["LooseSprites\\Lighting\\lantern"] = (content, key) => Game1.lantern = content.Load<Texture2D>(key), - ["LooseSprites\\Lighting\\windowLight"] = (content, key) => Game1.windowLight = content.Load<Texture2D>(key), - ["LooseSprites\\Lighting\\sconceLight"] = (content, key) => Game1.sconceLight = content.Load<Texture2D>(key), - ["LooseSprites\\Lighting\\greenLight"] = (content, key) => Game1.cauldronLight = content.Load<Texture2D>(key), - ["LooseSprites\\Lighting\\indoorWindowLight"] = (content, key) => Game1.indoorWindowLight = content.Load<Texture2D>(key), - ["LooseSprites\\shadow"] = (content, key) => Game1.shadowTexture = content.Load<Texture2D>(key), - ["LooseSprites\\Cursors"] = (content, key) => Game1.mouseCursors = content.Load<Texture2D>(key), - ["LooseSprites\\ControllerMaps"] = (content, key) => Game1.controllerMaps = content.Load<Texture2D>(key), - ["TileSheets\\animations"] = (content, key) => Game1.animations = content.Load<Texture2D>(key), - ["Data\\Achievements"] = (content, key) => Game1.achievements = content.Load<Dictionary<int, string>>(key), - ["Data\\NPCGiftTastes"] = (content, key) => Game1.NPCGiftTastes = content.Load<Dictionary<string, string>>(key), - ["Fonts\\SpriteFont1"] = (content, key) => Game1.dialogueFont = content.Load<SpriteFont>(key), - ["Fonts\\SmallFont"] = (content, key) => Game1.smallFont = content.Load<SpriteFont>(key), - ["Fonts\\tinyFont"] = (content, key) => Game1.tinyFont = content.Load<SpriteFont>(key), - ["Fonts\\tinyFontBorder"] = (content, key) => Game1.tinyFontBorder = content.Load<SpriteFont>(key), - ["Maps\\springobjects"] = (content, key) => Game1.objectSpriteSheet = content.Load<Texture2D>(key), - ["TileSheets\\crops"] = (content, key) => Game1.cropSpriteSheet = content.Load<Texture2D>(key), - ["TileSheets\\emotes"] = (content, key) => Game1.emoteSpriteSheet = content.Load<Texture2D>(key), - ["TileSheets\\debris"] = (content, key) => Game1.debrisSpriteSheet = content.Load<Texture2D>(key), - ["TileSheets\\Craftables"] = (content, key) => Game1.bigCraftableSpriteSheet = content.Load<Texture2D>(key), - ["TileSheets\\rain"] = (content, key) => Game1.rainTexture = content.Load<Texture2D>(key), - ["TileSheets\\BuffsIcons"] = (content, key) => Game1.buffsIcons = content.Load<Texture2D>(key), - ["Data\\ObjectInformation"] = (content, key) => Game1.objectInformation = content.Load<Dictionary<int, string>>(key), - ["Data\\BigCraftablesInformation"] = (content, key) => Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key), - ["Characters\\Farmer\\hairstyles"] = (content, key) => FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key), - ["Characters\\Farmer\\shirts"] = (content, key) => FarmerRenderer.shirtsTexture = content.Load<Texture2D>(key), - ["Characters\\Farmer\\hats"] = (content, key) => FarmerRenderer.hatsTexture = content.Load<Texture2D>(key), - ["Characters\\Farmer\\accessories"] = (content, key) => FarmerRenderer.accessoriesTexture = content.Load<Texture2D>(key), - ["TileSheets\\furniture"] = (content, key) => Furniture.furnitureTexture = content.Load<Texture2D>(key), - ["LooseSprites\\font_bold"] = (content, key) => SpriteText.spriteTexture = content.Load<Texture2D>(key), - ["LooseSprites\\font_colored"] = (content, key) => SpriteText.coloredTexture = content.Load<Texture2D>(key), - ["TileSheets\\weapons"] = (content, key) => Tool.weaponsTexture = content.Load<Texture2D>(key), - ["TileSheets\\Projectiles"] = (content, key) => Projectile.projectileSheet = content.Load<Texture2D>(key), - - // from Game1.ResetToolSpriteSheet - ["TileSheets\\tools"] = (content, key) => Game1.ResetToolSpriteSheet(), - -#if STARDEW_VALLEY_1_3 - // from Bush - ["TileSheets\\bushes"] = (content, key) => reflection.GetField<Lazy<Texture2D>>(typeof(Bush), "texture").SetValue(new Lazy<Texture2D>(() => content.Load<Texture2D>(key))), - - // from Farm - ["Buildings\\houses"] = (content, key) => reflection.GetField<Texture2D>(typeof(Farm), nameof(Farm.houseTextures)).SetValue(content.Load<Texture2D>(key)), - - // from Farmer - ["Characters\\Farmer\\farmer_base"] = (content, key) => - { - if (Game1.player != null && Game1.player.isMale) - Game1.player.FarmerRenderer = new FarmerRenderer(key); - }, - ["Characters\\Farmer\\farmer_girl_base"] = (content, key) => - { - if (Game1.player != null && !Game1.player.isMale) - Game1.player.FarmerRenderer = new FarmerRenderer(key); - }, -#else - // from Bush - ["TileSheets\\bushes"] = (content, key) => Bush.texture = content.Load<Texture2D>(key), - - // from Critter - ["TileSheets\\critters"] = (content, key) => Critter.critterTexture = content.Load<Texture2D>(key), - - // from Farm - ["Buildings\\houses"] = (content, key) => - { - Farm farm = Game1.getFarm(); - if (farm != null) - farm.houseTextures = content.Load<Texture2D>(key); - }, - - // from Farmer - ["Characters\\Farmer\\farmer_base"] = (content, key) => - { - if (Game1.player != null && Game1.player.isMale) - Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); - }, - ["Characters\\Farmer\\farmer_girl_base"] = (content, key) => - { - if (Game1.player != null && !Game1.player.isMale) - Game1.player.FarmerRenderer = new FarmerRenderer(content.Load<Texture2D>(key)); - }, -#endif - - // from Flooring - ["TerrainFeatures\\Flooring"] = (content, key) => Flooring.floorsTexture = content.Load<Texture2D>(key), - - // from FruitTree - ["TileSheets\\fruitTrees"] = (content, key) => FruitTree.texture = content.Load<Texture2D>(key), - - // from HoeDirt - ["TerrainFeatures\\hoeDirt"] = (content, key) => HoeDirt.lightTexture = content.Load<Texture2D>(key), - ["TerrainFeatures\\hoeDirtDark"] = (content, key) => HoeDirt.darkTexture = content.Load<Texture2D>(key), - ["TerrainFeatures\\hoeDirtSnow"] = (content, key) => HoeDirt.snowTexture = content.Load<Texture2D>(key), - - // from TitleMenu - ["Minigames\\Clouds"] = (content, key) => - { - if (Game1.activeClickableMenu is TitleMenu) - reflection.GetField<Texture2D>(Game1.activeClickableMenu, "cloudsTexture").SetValue(content.Load<Texture2D>(key)); - }, - ["Minigames\\TitleButtons"] = (content, key) => - { - if (Game1.activeClickableMenu is TitleMenu titleMenu) - { - reflection.GetField<Texture2D>(titleMenu, "titleButtonsTexture").SetValue(content.Load<Texture2D>(key)); - foreach (TemporaryAnimatedSprite bird in reflection.GetField<List<TemporaryAnimatedSprite>>(titleMenu, "birds").GetValue()) -#if STARDEW_VALLEY_1_3 - bird.texture = content.Load<Texture2D>(key); -#else - bird.Texture = content.Load<Texture2D>(key); -#endif - } - }, - - // from Wallpaper - ["Maps\\walls_and_floors"] = (content, key) => Wallpaper.wallpaperTexture = content.Load<Texture2D>(key) - } - .ToDictionary(p => getNormalisedPath(p.Key), p => p.Value); - } - - /// <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> - /// <returns>Returns whether an asset was reloaded.</returns> - public bool ReloadForKey(LocalizedContentManager content, string key) - { - // static assets - if (this.SingletonSetters.TryGetValue(key, out Action<LocalizedContentManager, string> reload)) - { - reload(content, key); - return true; - } - - // building textures - if (key.StartsWith(this.GetNormalisedPath("Buildings\\"))) - { - Building[] buildings = this.GetAllBuildings().Where(p => key == this.GetNormalisedPath($"Buildings\\{p.buildingType}")).ToArray(); - if (buildings.Any()) - { -#if STARDEW_VALLEY_1_3 - foreach (Building building in buildings) - building.texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key)); -#else - Texture2D texture = content.Load<Texture2D>(key); - foreach (Building building in buildings) - building.texture = texture; -#endif - - return true; - } - return false; - } - - return false; - } - - - /********* - ** Private methods - *********/ - /// <summary>Get all player-constructed buildings in the world.</summary> - private IEnumerable<Building> GetAllBuildings() - { - foreach (BuildableGameLocation location in Game1.locations.OfType<BuildableGameLocation>()) - { - foreach (Building building in location.buildings) - yield return building; - } - } - } -} diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs index 4bd40710..1b8cb2ba 100644 --- a/src/SMAPI/Program.cs +++ b/src/SMAPI/Program.cs @@ -541,8 +541,10 @@ namespace StardewModdingAPI 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: {response.Error}"); } - else if (new SemanticVersion(response.Version).IsNewerThan(Constants.ApiVersion)) - this.Monitor.Log($"You can update SMAPI to {response.Version}: {response.Url}", LogLevel.Alert); + else if (this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.Version))) + this.Monitor.Log($"You can update SMAPI to {response.Version}: {Constants.HomePageUrl}", LogLevel.Alert); + else if (response.PreviewVersion != null && this.IsValidUpdate(Constants.ApiVersion, new SemanticVersion(response.PreviewVersion))) + this.Monitor.Log($"You can update SMAPI to {response.PreviewVersion}: {Constants.HomePageUrl}", LogLevel.Alert); else this.Monitor.Log(" SMAPI okay.", LogLevel.Trace); } @@ -656,6 +658,27 @@ namespace StardewModdingAPI }).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> + private bool IsValidUpdate(ISemanticVersion currentVersion, ISemanticVersion newVersion) + { + // basic eligibility + bool isNewer = newVersion.IsNewerThan(currentVersion); + bool isPrerelease = newVersion.Build != null; + bool isEquallyStable = !isPrerelease || currentVersion.Build != null; // don't update stable => prerelease + if (!isNewer || !isEquallyStable) + return false; + if (!isPrerelease) + return true; + + // prerelease eligible if same version (excluding prerelease tag) + return + newVersion.MajorVersion == currentVersion.MajorVersion + && newVersion.MinorVersion == currentVersion.MinorVersion + && newVersion.PatchVersion == currentVersion.PatchVersion; + } + /// <summary>Create a directory path if it doesn't exist.</summary> /// <param name="path">The directory path.</param> private void VerifyPath(string path) @@ -930,7 +953,7 @@ namespace StardewModdingAPI { helper.ObservableAssetEditors.CollectionChanged += (sender, e) => { - if (e.NewItems.Count > 0) + if (e.NewItems?.Count > 0) { this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace); this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]); @@ -938,7 +961,7 @@ namespace StardewModdingAPI }; helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => { - if (e.NewItems.Count > 0) + if (e.NewItems?.Count > 0) { this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace); this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray()); diff --git a/src/SMAPI/StardewModdingAPI.csproj b/src/SMAPI/StardewModdingAPI.csproj index bffb96e2..edddbd2a 100644 --- a/src/SMAPI/StardewModdingAPI.csproj +++ b/src/SMAPI/StardewModdingAPI.csproj @@ -66,7 +66,8 @@ <Private>True</Private> </Reference> <Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL"> - <HintPath>..\packages\Newtonsoft.Json.11.0.1-beta3\lib\net45\Newtonsoft.Json.dll</HintPath> + <HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath> + <Private>True</Private> </Reference> <Reference Include="System" /> <Reference Include="System.Core" /> @@ -137,7 +138,7 @@ <Compile Include="IReflectedField.cs" /> <Compile Include="IReflectedMethod.cs" /> <Compile Include="IReflectedProperty.cs" /> - <Compile Include="Metadata\CoreAssets.cs" /> + <Compile Include="Metadata\CoreAssetPropagator.cs" /> <Compile Include="ContentSource.cs" /> <Compile Include="Events\ContentEvents.cs" /> <Compile Include="Events\EventArgsInput.cs" /> diff --git a/src/SMAPI/packages.config b/src/SMAPI/packages.config index 1a0b78fa..3e876922 100644 --- a/src/SMAPI/packages.config +++ b/src/SMAPI/packages.config @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> <packages> <package id="Mono.Cecil" version="0.9.6.4" targetFramework="net45" /> - <package id="Newtonsoft.Json" version="11.0.1-beta3" targetFramework="net45" /> + <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" /> </packages>
\ No newline at end of file |