summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/SMAPI.Common/Models/ModInfoModel.cs12
-rw-r--r--src/SMAPI.ModBuildConfig/build/smapi.targets17
-rw-r--r--src/SMAPI.ModBuildConfig/package.nuspec3
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/StardewModdingAPI.Mods.ConsoleCommands.csproj4
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json2
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/packages.config2
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs53
-rw-r--r--src/SMAPI.Web/Controllers/ModsApiController.cs16
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs50
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs4
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/IGitHubClient.cs5
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs7
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ModUpdateCheckConfig.cs7
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/ChucklefishRepository.cs2
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/GitHubRepository.cs19
-rw-r--r--src/SMAPI.Web/Framework/ModRepositories/NexusRepository.cs2
-rw-r--r--src/SMAPI.Web/StardewModdingAPI.Web.csproj14
-rw-r--r--src/SMAPI.Web/Startup.cs3
-rw-r--r--src/SMAPI.Web/ViewModels/IndexModel.cs28
-rw-r--r--src/SMAPI.Web/ViewModels/IndexVersionModel.cs41
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml35
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml34
-rw-r--r--src/SMAPI.Web/appsettings.json6
-rw-r--r--src/SMAPI/Constants.cs8
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs2
-rw-r--r--src/SMAPI/Framework/ContentCore.cs6
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs642
-rw-r--r--src/SMAPI/Metadata/CoreAssets.cs220
-rw-r--r--src/SMAPI/Program.cs31
-rw-r--r--src/SMAPI/StardewModdingAPI.csproj5
-rw-r--r--src/SMAPI/packages.config2
31 files changed, 947 insertions, 335 deletions
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