summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build/GlobalAssemblyInfo.cs4
-rw-r--r--build/prepare-nuget-package.targets20
-rw-r--r--docs/mod-build-config.md81
-rw-r--r--docs/release-notes.md18
-rw-r--r--docs/screenshots/code-analyzer-example.pngbin0 -> 4022 bytes
-rw-r--r--docs/technical-docs.md29
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs88
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs173
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs248
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs16
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs6
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs6
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs11
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs24
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs12
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs140
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs88
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj18
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs271
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs98
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs4
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj23
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps158
-rw-r--r--src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps165
-rw-r--r--src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj1
-rw-r--r--src/SMAPI.ModBuildConfig/build/smapi.targets2
-rw-r--r--src/SMAPI.ModBuildConfig/package.nuspec24
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json2
-rw-r--r--src/SMAPI.Tests/StardewModdingAPI.Tests.csproj27
-rw-r--r--src/SMAPI.Tests/app.config11
-rw-r--r--src/SMAPI.Tests/packages.config8
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs2
-rw-r--r--src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs4
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs1
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs3
-rw-r--r--src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs20
-rw-r--r--src/SMAPI.Web/Startup.cs1
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml57
-rw-r--r--src/SMAPI.Web/appsettings.json2
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/log-parser.css38
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/log-parser.js4
-rw-r--r--src/SMAPI.sln33
-rw-r--r--src/SMAPI/Constants.cs4
-rw-r--r--src/SMAPI/Context.cs8
-rw-r--r--src/SMAPI/Framework/ContentCore.cs20
-rw-r--r--src/SMAPI/Framework/ModLoading/ModResolver.cs1
-rw-r--r--src/SMAPI/Framework/SGame.cs194
-rw-r--r--src/SMAPI/Program.cs4
-rw-r--r--src/SMAPI/StardewModdingAPI.config.json2
49 files changed, 1802 insertions, 172 deletions
diff --git a/build/GlobalAssemblyInfo.cs b/build/GlobalAssemblyInfo.cs
index d2cf8fe7..f2477486 100644
--- a/build/GlobalAssemblyInfo.cs
+++ b/build/GlobalAssemblyInfo.cs
@@ -1,5 +1,5 @@
using System.Reflection;
[assembly: AssemblyProduct("SMAPI")]
-[assembly: AssemblyVersion("2.5.4")]
-[assembly: AssemblyFileVersion("2.5.4")]
+[assembly: AssemblyVersion("2.5.5")]
+[assembly: AssemblyFileVersion("2.5.5")]
diff --git a/build/prepare-nuget-package.targets b/build/prepare-nuget-package.targets
new file mode 100644
index 00000000..5dbc5508
--- /dev/null
+++ b/build/prepare-nuget-package.targets
@@ -0,0 +1,20 @@
+<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+ <!--
+
+ This build task is run from the ModBuildConfig project after it's been compiled, and copies the
+ package files to the bin\Pathoschild.Stardew.ModBuildConfig folder.
+
+ -->
+ <Target Name="AfterBuild">
+ <PropertyGroup>
+ <PackagePath>$(SolutionDir)\..\bin\Pathoschild.Stardew.ModBuildConfig</PackagePath>
+ </PropertyGroup>
+ <RemoveDir Directories="$(PackagePath)" />
+ <Copy SourceFiles="$(ProjectDir)/package.nuspec" DestinationFolder="$(PackagePath)" />
+ <Copy SourceFiles="$(ProjectDir)/build/smapi.targets" DestinationFiles="$(PackagePath)/build/Pathoschild.Stardew.ModBuildConfig.targets" />
+ <Copy SourceFiles="$(TargetDir)/StardewModdingAPI.ModBuildConfig.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.ModBuildConfig.dll" />
+ <Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/bin/netstandard1.3/StardewModdingAPI.ModBuildConfig.Analyzer.dll" DestinationFiles="$(PackagePath)/analyzers/dotnet/cs/StardewModdingAPI.ModBuildConfig.Analyzer.dll" />
+ <Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1" DestinationFiles="$(PackagePath)/tools/install.ps1" />
+ <Copy SourceFiles="$(SolutionDir)/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1" DestinationFiles="$(PackagePath)/tools/uninstall.ps1" />
+ </Target>
+</Project>
diff --git a/docs/mod-build-config.md b/docs/mod-build-config.md
index 0d72e4d9..99a567f2 100644
--- a/docs/mod-build-config.md
+++ b/docs/mod-build-config.md
@@ -3,15 +3,16 @@ for SMAPI mods.
The package...
-* lets your code compile on any computer (Linux/Mac/Windows) without needing to change the assembly
- references or game path.
-* packages the mod into the game's `Mods` folder when you rebuild the code (configurable).
-* configures Visual Studio so you can debug into the mod code when the game is running (_Windows
- only_).
+* detects your game install path;
+* adds the assembly references you need (with automatic support for Linux/Mac/Windows);
+* packages the mod into your `Mods` folder when you rebuild the code (configurable);
+* configures Visual Studio to enable debugging into the code when the game is running (_Windows only_);
+* adds C# analyzers to warn for Stardew Valley-specific issues.
## Contents
* [Install](#install)
* [Configure](#configure)
+* [Code analysis warnings](#code-analysis-warnings)
* [Troubleshoot](#troubleshoot)
* [Release notes](#release-notes)
@@ -121,7 +122,7 @@ The configuration will check your custom path first, then fall back to the defau
still compile on a different computer).
### Unit test projects
-**(upcoming in 2.0.3)**
+**(upcoming in 2.1)**
You can use the package in unit test projects too. Its optional unit test mode...
@@ -134,15 +135,81 @@ To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
<ModUnitTests>True</ModUnitTests>
```
+## Code warnings
+### Overview
+The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example:
+![](screenshots/code-analyzer-example.png)
+
+You can hide the warnings...
+* [for specific code](https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-pragma-warning);
+* for a method using this attribute:
+ ```cs
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "SMAPI001")] // implicit net field conversion
+ ```
+* for an entire project:
+ 1. Expand the _References_ node for the project in Visual Studio.
+ 2. Right-click on _Analyzers_ and choose _Open Active Rule Set_.
+ 4. Expand _StardewModdingAPI.ModBuildConfig.Analyzer_ and uncheck the warnings you want to hide.
+
+See below for help with each specific warning.
+
+### SMAPI001
+**Implicit net field conversion:**
+> This implicitly converts '{{expression}}' from {{net type}} to {{other type}}, but
+> {{net type}} has unintuitive implicit conversion rules. Consider comparing against the actual
+> value instead to avoid bugs.
+
+Stardew Valley uses net types (like `NetBool` and `NetInt`) to handle multiplayer sync. These types
+can implicitly convert to their equivalent normal values (like `bool x = new NetBool()`), but their
+conversion rules are unintuitive and error-prone. For example,
+`item?.category == null && item?.category != null` can both be true at once, and
+`building.indoors != null` will be true for a null value in some cases.
+
+Suggested fix:
+* Some net fields have an equivalent non-net property like `monster.Health` (`int`) instead of
+ `monster.health` (`NetInt`). The package will add a separate [SMAPI002](#smapi002) warning for
+ these. Use the suggested property instead.
+* For a reference type (i.e. one that can contain `null`), you can use the `.Value` property:
+ ```c#
+ if (building.indoors.Value == null)
+ ```
+ Or convert the value before comparison:
+ ```c#
+ GameLocation indoors = building.indoors;
+ if(indoors == null)
+ // ...
+ ```
+* For a value type (i.e. one that can't contain `null`), check if the object is null (if applicable)
+ and compare with `.Value`:
+ ```cs
+ if (item != null && item.category.Value == 0)
+ ```
+
+### SMAPI002
+**Avoid net fields when possible:**
+> '{{expression}}' is a {{net type}} field; consider using the {{property name}} property instead.
+
+Your code accesses a net field, which has some unusual behavior (see [SMAPI001](#smapi001)). This
+field has an equivalent non-net property that avoids those issues.
+
+Suggested fix: access the suggested property name instead.
+
+### SMAPI003
+**Avoid obsolete fields:**
+> The '{{old field}}' field is obsolete and should be replaced with '{{new field}}'.
+
+Your code accesses a field which is obsolete or no longer works. Use the suggested field instead.
+
## Troubleshoot
### "Failed to find the game install path"
That error means the package couldn't find your game. You can specify the game path yourself; see
_[Game path](#game-path)_ above.
## Release notes
-### 2.0.3 alpha
+### 2.1 alpha
* Added support for Stardew Valley 1.3.
* Added support for unit test projects.
+* Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
### 2.0.2
* Fixed compatibility issue on Linux.
diff --git a/docs/release-notes.md b/docs/release-notes.md
index b3300800..e68720da 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -3,18 +3,32 @@
## 2.6 alpha
* For players:
* 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).
+ * Added `Context.IsMultiplayer` and `Context.IsMainPlayer` flags.
* Fixed SMAPI update checks not showing newer beta versions when using a beta version.
* For modders:
+ * Added code analysis to mod build config package to flag common issues as warnings.
* Dropped some deprecated APIs.
- * Fixed some assets not being editable.
+ * Fixed assets loaded by temporary content managers not being editable.
+ * Fixed issue where assets didn't reload correctly when the player switches language.
* 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.5
+* For players:
+ * Fixed mod not loaded if it has an optional dependency that's loaded but skipped.
+ * Fixed mod update alerts not shown if one mod has an invalid remote version.
+ * Fixed SMAPI update alerts linking to the GitHub repository instead of [smapi.io](https://smapi.io).
+ * Fixed SMAPI update alerts for draft releases.
+ * Fixed error when two content packs use different capitalisation for the same required mod ID.
+ * Fixed rare crash if the game duplicates an item.
+
+* For the [log parser][]:
+ * Tweaked UI.
+
## 2.5.4
* For players:
* Fixed some textures not updated when a mod changes them.
diff --git a/docs/screenshots/code-analyzer-example.png b/docs/screenshots/code-analyzer-example.png
new file mode 100644
index 00000000..3b930dc5
--- /dev/null
+++ b/docs/screenshots/code-analyzer-example.png
Binary files differ
diff --git a/docs/technical-docs.md b/docs/technical-docs.md
index 37ec7f69..9e1a49e7 100644
--- a/docs/technical-docs.md
+++ b/docs/technical-docs.md
@@ -20,6 +20,7 @@ mods, this section isn't relevant to you; see the main README to use or create m
* [Development](#development-2)
* [Local development](#local-development)
* [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
+* [Mod build config package](#mod-build-config-package)
# SMAPI
## Development
@@ -222,3 +223,31 @@ property name | description
`LogParser:SectionUrl` | The root URL of the log page, like `https://log.smapi.io/`.
`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info.
`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info.
+
+## Mod build config package
+### Overview
+The mod build config package is a NuGet package that mods reference to automatically set up
+references, configure the build, and add analyzers specific to Stardew Valley mods.
+
+This involves three projects:
+
+project | purpose
+------------------------------------------------- | ----------------
+`StardewModdingAPI.ModBuildConfig` | Configures the build (references, deploying the mod files, setting up debugging, etc).
+`StardewModdingAPI.ModBuildConfig.Analyzer` | Adds C# analyzers which show code warnings in Visual Studio.
+`StardewModdingAPI.ModBuildConfig.Analyzer.Tests` | Unit tests for the C# analyzers.
+
+When the projects are built, the relevant files are copied into `bin/Pathoschild.Stardew.ModBuildConfig`.
+
+### Preparing a build
+To prepare a build of the NuGet package:
+1. Install the [NuGet CLI](https://docs.microsoft.com/en-us/nuget/install-nuget-client-tools#nugetexe-cli).
+1. Change the version and release notes in `package.nuspec`.
+2. Rebuild the solution in _Release_ mode.
+3. Open a terminal in the `bin/Pathoschild.Stardew.ModBuildConfig` package and run this command:
+ ```bash
+ nuget.exe pack
+ ```
+
+That will create a `Pathoschild.Stardew.ModBuildConfig-<version>.nupkg` file in the same directory
+which can be uploaded to NuGet or referenced directly.
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs
new file mode 100644
index 00000000..896c2cb8
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticResult.cs
@@ -0,0 +1,88 @@
+// <generated />
+using Microsoft.CodeAnalysis;
+using System;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
+{
+ /// <summary>
+ /// Location where the diagnostic appears, as determined by path, line number, and column number.
+ /// </summary>
+ public struct DiagnosticResultLocation
+ {
+ public DiagnosticResultLocation(string path, int line, int column)
+ {
+ if (line < -1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(line), "line must be >= -1");
+ }
+
+ if (column < -1)
+ {
+ throw new ArgumentOutOfRangeException(nameof(column), "column must be >= -1");
+ }
+
+ this.Path = path;
+ this.Line = line;
+ this.Column = column;
+ }
+
+ public string Path { get; }
+ public int Line { get; }
+ public int Column { get; }
+ }
+
+ /// <summary>
+ /// Struct that stores information about a Diagnostic appearing in a source
+ /// </summary>
+ public struct DiagnosticResult
+ {
+ private DiagnosticResultLocation[] locations;
+
+ public DiagnosticResultLocation[] Locations
+ {
+ get
+ {
+ if (this.locations == null)
+ {
+ this.locations = new DiagnosticResultLocation[] { };
+ }
+ return this.locations;
+ }
+
+ set
+ {
+ this.locations = value;
+ }
+ }
+
+ public DiagnosticSeverity Severity { get; set; }
+
+ public string Id { get; set; }
+
+ public string Message { get; set; }
+
+ public string Path
+ {
+ get
+ {
+ return this.Locations.Length > 0 ? this.Locations[0].Path : "";
+ }
+ }
+
+ public int Line
+ {
+ get
+ {
+ return this.Locations.Length > 0 ? this.Locations[0].Line : -1;
+ }
+ }
+
+ public int Column
+ {
+ get
+ {
+ return this.Locations.Length > 0 ? this.Locations[0].Column : -1;
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs
new file mode 100644
index 00000000..0247288e
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.Helper.cs
@@ -0,0 +1,173 @@
+// <generated />
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Text;
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
+{
+ /// <summary>
+ /// Class for turning strings into documents and getting the diagnostics on them
+ /// All methods are static
+ /// </summary>
+ public abstract partial class DiagnosticVerifier
+ {
+ private static readonly MetadataReference CorlibReference = MetadataReference.CreateFromFile(typeof(object).Assembly.Location);
+ private static readonly MetadataReference SystemCoreReference = MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location);
+ private static readonly MetadataReference CSharpSymbolsReference = MetadataReference.CreateFromFile(typeof(CSharpCompilation).Assembly.Location);
+ private static readonly MetadataReference CodeAnalysisReference = MetadataReference.CreateFromFile(typeof(Compilation).Assembly.Location);
+ private static readonly MetadataReference SelfReference = MetadataReference.CreateFromFile(typeof(DiagnosticVerifier).Assembly.Location);
+
+ internal static string DefaultFilePathPrefix = "Test";
+ internal static string CSharpDefaultFileExt = "cs";
+ internal static string VisualBasicDefaultExt = "vb";
+ internal static string TestProjectName = "TestProject";
+
+ #region Get Diagnostics
+
+ /// <summary>
+ /// Given classes in the form of strings, their language, and an IDiagnosticAnalyzer to apply to it, return the diagnostics found in the string after converting it to a document.
+ /// </summary>
+ /// <param name="sources">Classes in the form of strings</param>
+ /// <param name="language">The language the source classes are in</param>
+ /// <param name="analyzer">The analyzer to be run on the sources</param>
+ /// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns>
+ private static Diagnostic[] GetSortedDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer)
+ {
+ return GetSortedDiagnosticsFromDocuments(analyzer, GetDocuments(sources, language));
+ }
+
+ /// <summary>
+ /// Given an analyzer and a document to apply it to, run the analyzer and gather an array of diagnostics found in it.
+ /// The returned diagnostics are then ordered by location in the source document.
+ /// </summary>
+ /// <param name="analyzer">The analyzer to run on the documents</param>
+ /// <param name="documents">The Documents that the analyzer will be run on</param>
+ /// <returns>An IEnumerable of Diagnostics that surfaced in the source code, sorted by Location</returns>
+ protected static Diagnostic[] GetSortedDiagnosticsFromDocuments(DiagnosticAnalyzer analyzer, Document[] documents)
+ {
+ var projects = new HashSet<Project>();
+ foreach (var document in documents)
+ {
+ projects.Add(document.Project);
+ }
+
+ var diagnostics = new List<Diagnostic>();
+ foreach (var project in projects)
+ {
+ var compilationWithAnalyzers = project.GetCompilationAsync().Result.WithAnalyzers(ImmutableArray.Create(analyzer));
+ var diags = compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().Result;
+ foreach (var diag in diags)
+ {
+ if (diag.Location == Location.None || diag.Location.IsInMetadata)
+ {
+ diagnostics.Add(diag);
+ }
+ else
+ {
+ for (int i = 0; i < documents.Length; i++)
+ {
+ var document = documents[i];
+ var tree = document.GetSyntaxTreeAsync().Result;
+ if (tree == diag.Location.SourceTree)
+ {
+ diagnostics.Add(diag);
+ }
+ }
+ }
+ }
+ }
+
+ var results = SortDiagnostics(diagnostics);
+ diagnostics.Clear();
+ return results;
+ }
+
+ /// <summary>
+ /// Sort diagnostics by location in source document
+ /// </summary>
+ /// <param name="diagnostics">The list of Diagnostics to be sorted</param>
+ /// <returns>An IEnumerable containing the Diagnostics in order of Location</returns>
+ private static Diagnostic[] SortDiagnostics(IEnumerable<Diagnostic> diagnostics)
+ {
+ return diagnostics.OrderBy(d => d.Location.SourceSpan.Start).ToArray();
+ }
+
+ #endregion
+
+ #region Set up compilation and documents
+ /// <summary>
+ /// Given an array of strings as sources and a language, turn them into a project and return the documents and spans of it.
+ /// </summary>
+ /// <param name="sources">Classes in the form of strings</param>
+ /// <param name="language">The language the source code is in</param>
+ /// <returns>A Tuple containing the Documents produced from the sources and their TextSpans if relevant</returns>
+ private static Document[] GetDocuments(string[] sources, string language)
+ {
+ if (language != LanguageNames.CSharp && language != LanguageNames.VisualBasic)
+ {
+ throw new ArgumentException("Unsupported Language");
+ }
+
+ var project = CreateProject(sources, language);
+ var documents = project.Documents.ToArray();
+
+ if (sources.Length != documents.Length)
+ {
+ throw new InvalidOperationException("Amount of sources did not match amount of Documents created");
+ }
+
+ return documents;
+ }
+
+ /// <summary>
+ /// Create a Document from a string through creating a project that contains it.
+ /// </summary>
+ /// <param name="source">Classes in the form of a string</param>
+ /// <param name="language">The language the source code is in</param>
+ /// <returns>A Document created from the source string</returns>
+ protected static Document CreateDocument(string source, string language = LanguageNames.CSharp)
+ {
+ return CreateProject(new[] { source }, language).Documents.First();
+ }
+
+ /// <summary>
+ /// Create a project using the inputted strings as sources.
+ /// </summary>
+ /// <param name="sources">Classes in the form of strings</param>
+ /// <param name="language">The language the source code is in</param>
+ /// <returns>A Project created out of the Documents created from the source strings</returns>
+ private static Project CreateProject(string[] sources, string language = LanguageNames.CSharp)
+ {
+ string fileNamePrefix = DefaultFilePathPrefix;
+ string fileExt = language == LanguageNames.CSharp ? CSharpDefaultFileExt : VisualBasicDefaultExt;
+
+ var projectId = ProjectId.CreateNewId(debugName: TestProjectName);
+
+ var solution = new AdhocWorkspace()
+ .CurrentSolution
+ .AddProject(projectId, TestProjectName, TestProjectName, language)
+ .AddMetadataReference(projectId, DiagnosticVerifier.SelfReference)
+ .AddMetadataReference(projectId, CorlibReference)
+ .AddMetadataReference(projectId, SystemCoreReference)
+ .AddMetadataReference(projectId, CSharpSymbolsReference)
+ .AddMetadataReference(projectId, CodeAnalysisReference);
+
+ int count = 0;
+ foreach (var source in sources)
+ {
+ var newFileName = fileNamePrefix + count + "." + fileExt;
+ var documentId = DocumentId.CreateNewId(projectId, debugName: newFileName);
+ solution = solution.AddDocument(documentId, newFileName, SourceText.From(source));
+ count++;
+ }
+ return solution.GetProject(projectId);
+ }
+ #endregion
+ }
+}
+
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs
new file mode 100644
index 00000000..edaaabd4
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Framework/DiagnosticVerifier.cs
@@ -0,0 +1,248 @@
+// <generated />
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using NUnit.Framework;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests.Framework
+{
+ /// <summary>
+ /// Superclass of all Unit Tests for DiagnosticAnalyzers
+ /// </summary>
+ public abstract partial class DiagnosticVerifier
+ {
+ #region To be implemented by Test classes
+ /// <summary>
+ /// Get the CSharp analyzer being tested - to be implemented in non-abstract class
+ /// </summary>
+ protected virtual DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ {
+ return null;
+ }
+
+ /// <summary>
+ /// Get the Visual Basic analyzer being tested (C#) - to be implemented in non-abstract class
+ /// </summary>
+ protected virtual DiagnosticAnalyzer GetBasicDiagnosticAnalyzer()
+ {
+ return null;
+ }
+ #endregion
+
+ #region Verifier wrappers
+
+ /// <summary>
+ /// Called to test a C# DiagnosticAnalyzer when applied on the single inputted string as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ /// </summary>
+ /// <param name="source">A class in the form of a string to run the analyzer on</param>
+ /// <param name="expected"> DiagnosticResults that should appear after the analyzer is run on the source</param>
+ protected void VerifyCSharpDiagnostic(string source, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(new[] { source }, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
+ }
+
+ /// <summary>
+ /// Called to test a C# DiagnosticAnalyzer when applied on the inputted strings as a source
+ /// Note: input a DiagnosticResult for each Diagnostic expected
+ /// </summary>
+ /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param>
+ /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
+ protected void VerifyCSharpDiagnostic(string[] sources, params DiagnosticResult[] expected)
+ {
+ VerifyDiagnostics(sources, LanguageNames.CSharp, GetCSharpDiagnosticAnalyzer(), expected);
+ }
+
+ /// <summary>
+ /// General method that gets a collection of actual diagnostics found in the source after the analyzer is run,
+ /// then verifies each of them.
+ /// </summary>
+ /// <param name="sources">An array of strings to create source documents from to run the analyzers on</param>
+ /// <param name="language">The language of the classes represented by the source strings</param>
+ /// <param name="analyzer">The analyzer to be run on the source code</param>
+ /// <param name="expected">DiagnosticResults that should appear after the analyzer is run on the sources</param>
+ private void VerifyDiagnostics(string[] sources, string language, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expected)
+ {
+ var diagnostics = GetSortedDiagnostics(sources, language, analyzer);
+ VerifyDiagnosticResults(diagnostics, analyzer, expected);
+ }
+
+ #endregion
+
+ #region Actual comparisons and verifications
+ /// <summary>
+ /// Checks each of the actual Diagnostics found and compares them with the corresponding DiagnosticResult in the array of expected results.
+ /// Diagnostics are considered equal only if the DiagnosticResultLocation, Id, Severity, and Message of the DiagnosticResult match the actual diagnostic.
+ /// </summary>
+ /// <param name="actualResults">The Diagnostics found by the compiler after running the analyzer on the source code</param>
+ /// <param name="analyzer">The analyzer that was being run on the sources</param>
+ /// <param name="expectedResults">Diagnostic Results that should have appeared in the code</param>
+ private static void VerifyDiagnosticResults(IEnumerable<Diagnostic> actualResults, DiagnosticAnalyzer analyzer, params DiagnosticResult[] expectedResults)
+ {
+ int expectedCount = expectedResults.Count();
+ int actualCount = actualResults.Count();
+
+ if (expectedCount != actualCount)
+ {
+ string diagnosticsOutput = actualResults.Any() ? FormatDiagnostics(analyzer, actualResults.ToArray()) : " NONE.";
+
+ Assert.IsTrue(false,
+ string.Format("Mismatch between number of diagnostics returned, expected \"{0}\" actual \"{1}\"\r\n\r\nDiagnostics:\r\n{2}\r\n", expectedCount, actualCount, diagnosticsOutput));
+ }
+
+ for (int i = 0; i < expectedResults.Length; i++)
+ {
+ var actual = actualResults.ElementAt(i);
+ var expected = expectedResults[i];
+
+ if (expected.Line == -1 && expected.Column == -1)
+ {
+ if (actual.Location != Location.None)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected:\nA project diagnostic with No location\nActual:\n{0}",
+ FormatDiagnostics(analyzer, actual)));
+ }
+ }
+ else
+ {
+ VerifyDiagnosticLocation(analyzer, actual, actual.Location, expected.Locations.First());
+ var additionalLocations = actual.AdditionalLocations.ToArray();
+
+ if (additionalLocations.Length != expected.Locations.Length - 1)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected {0} additional locations but got {1} for Diagnostic:\r\n {2}\r\n",
+ expected.Locations.Length - 1, additionalLocations.Length,
+ FormatDiagnostics(analyzer, actual)));
+ }
+
+ for (int j = 0; j < additionalLocations.Length; ++j)
+ {
+ VerifyDiagnosticLocation(analyzer, actual, additionalLocations[j], expected.Locations[j + 1]);
+ }
+ }
+
+ if (actual.Id != expected.Id)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected diagnostic id to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Id, actual.Id, FormatDiagnostics(analyzer, actual)));
+ }
+
+ if (actual.Severity != expected.Severity)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected diagnostic severity to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Severity, actual.Severity, FormatDiagnostics(analyzer, actual)));
+ }
+
+ if (actual.GetMessage() != expected.Message)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected diagnostic message to be \"{0}\" was \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Message, actual.GetMessage(), FormatDiagnostics(analyzer, actual)));
+ }
+ }
+ }
+
+ /// <summary>
+ /// Helper method to VerifyDiagnosticResult that checks the location of a diagnostic and compares it with the location in the expected DiagnosticResult.
+ /// </summary>
+ /// <param name="analyzer">The analyzer that was being run on the sources</param>
+ /// <param name="diagnostic">The diagnostic that was found in the code</param>
+ /// <param name="actual">The Location of the Diagnostic found in the code</param>
+ /// <param name="expected">The DiagnosticResultLocation that should have been found</param>
+ private static void VerifyDiagnosticLocation(DiagnosticAnalyzer analyzer, Diagnostic diagnostic, Location actual, DiagnosticResultLocation expected)
+ {
+ var actualSpan = actual.GetLineSpan();
+
+ Assert.IsTrue(actualSpan.Path == expected.Path || (actualSpan.Path != null && actualSpan.Path.Contains("Test0.") && expected.Path.Contains("Test.")),
+ string.Format("Expected diagnostic to be in file \"{0}\" was actually in file \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Path, actualSpan.Path, FormatDiagnostics(analyzer, diagnostic)));
+
+ var actualLinePosition = actualSpan.StartLinePosition;
+
+ // Only check line position if there is an actual line in the real diagnostic
+ if (actualLinePosition.Line > 0)
+ {
+ if (actualLinePosition.Line + 1 != expected.Line)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected diagnostic to be on line \"{0}\" was actually on line \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Line, actualLinePosition.Line + 1, FormatDiagnostics(analyzer, diagnostic)));
+ }
+ }
+
+ // Only check column position if there is an actual column position in the real diagnostic
+ if (actualLinePosition.Character > 0)
+ {
+ if (actualLinePosition.Character + 1 != expected.Column)
+ {
+ Assert.IsTrue(false,
+ string.Format("Expected diagnostic to start at column \"{0}\" was actually at column \"{1}\"\r\n\r\nDiagnostic:\r\n {2}\r\n",
+ expected.Column, actualLinePosition.Character + 1, FormatDiagnostics(analyzer, diagnostic)));
+ }
+ }
+ }
+ #endregion
+
+ #region Formatting Diagnostics
+ /// <summary>
+ /// Helper method to format a Diagnostic into an easily readable string
+ /// </summary>
+ /// <param name="analyzer">The analyzer that this verifier tests</param>
+ /// <param name="diagnostics">The Diagnostics to be formatted</param>
+ /// <returns>The Diagnostics formatted as a string</returns>
+ private static string FormatDiagnostics(DiagnosticAnalyzer analyzer, params Diagnostic[] diagnostics)
+ {
+ var builder = new StringBuilder();
+ for (int i = 0; i < diagnostics.Length; ++i)
+ {
+ builder.AppendLine("// " + diagnostics[i].ToString());
+
+ var analyzerType = analyzer.GetType();
+ var rules = analyzer.SupportedDiagnostics;
+
+ foreach (var rule in rules)
+ {
+ if (rule != null && rule.Id == diagnostics[i].Id)
+ {
+ var location = diagnostics[i].Location;
+ if (location == Location.None)
+ {
+ builder.AppendFormat("GetGlobalResult({0}.{1})", analyzerType.Name, rule.Id);
+ }
+ else
+ {
+ Assert.IsTrue(location.IsInSource,
+ $"Test base does not currently handle diagnostics in metadata locations. Diagnostic in metadata: {diagnostics[i]}\r\n");
+
+ string resultMethodName = diagnostics[i].Location.SourceTree.FilePath.EndsWith(".cs") ? "GetCSharpResultAt" : "GetBasicResultAt";
+ var linePosition = diagnostics[i].Location.GetLineSpan().StartLinePosition;
+
+ builder.AppendFormat("{0}({1}, {2}, {3}.{4})",
+ resultMethodName,
+ linePosition.Line + 1,
+ linePosition.Character + 1,
+ analyzerType.Name,
+ rule.Id);
+ }
+
+ if (i != diagnostics.Length - 1)
+ {
+ builder.Append(',');
+ }
+
+ builder.AppendLine();
+ break;
+ }
+ }
+ }
+ return builder.ToString();
+ }
+ #endregion
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs
new file mode 100644
index 00000000..1684229a
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetFieldBase.cs
@@ -0,0 +1,16 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetFieldBase</c> for unit testing.</summary>
+ /// <typeparam name="T">The type of the synchronised value.</typeparam>
+ /// <typeparam name="TSelf">The type of the current instance.</typeparam>
+ public class NetFieldBase<T, TSelf> where TSelf : NetFieldBase<T, TSelf>
+ {
+ /// <summary>The synchronised value.</summary>
+ public T Value { get; set; }
+
+ /// <summary>Implicitly convert a net field to the its type.</summary>
+ /// <param name="field">The field to convert.</param>
+ public static implicit operator T(NetFieldBase<T, TSelf> field) => field.Value;
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs
new file mode 100644
index 00000000..b3abc467
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetInt.cs
@@ -0,0 +1,6 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetInt</c> for unit testing.</summary>
+ public class NetInt : NetFieldBase<int, NetInt> { }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs
new file mode 100644
index 00000000..be2459cc
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/Netcode/NetRef.cs
@@ -0,0 +1,6 @@
+// ReSharper disable CheckNamespace -- matches Stardew Valley's code
+namespace Netcode
+{
+ /// <summary>A simplified version of Stardew Valley's <c>Netcode.NetRef</c> for unit testing.</summary>
+ public class NetRef<T> : NetFieldBase<T, NetRef<T>> where T : class { }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs
new file mode 100644
index 00000000..e0f0e30c
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Farmer.cs
@@ -0,0 +1,11 @@
+// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+using System.Collections.Generic;
+
+namespace StardewValley
+{
+ /// <summary>A simplified version of Stardew Valley's <c>StardewValley.Farmer</c> class for unit testing.</summary>
+ internal class Farmer
+ {
+ public IDictionary<string, int[]> friendships;
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs
new file mode 100644
index 00000000..386767d7
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Item.cs
@@ -0,0 +1,24 @@
+// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+using Netcode;
+
+namespace StardewValley
+{
+ /// <summary>A simplified version of Stardew Valley's <c>StardewValley.Item</c> class for unit testing.</summary>
+ public class Item
+ {
+ /// <summary>A net int field with an equivalent non-net <c>Category</c> property.</summary>
+ public readonly NetInt category = new NetInt { Value = 42 };
+
+ /// <summary>A generic net int field with no equivalent non-net property.</summary>
+ public readonly NetInt netIntField = new NetInt { Value = 42 };
+
+ /// <summary>A generic net ref field with no equivalent non-net property.</summary>
+ public readonly NetRef<object> netRefField = new NetRef<object>();
+
+ /// <summary>A generic net int property with no equivalent non-net property.</summary>
+ public NetInt netIntProperty = new NetInt { Value = 42 };
+
+ /// <summary>A generic net ref property with no equivalent non-net property.</summary>
+ public NetRef<object> netRefProperty { get; } = new NetRef<object>();
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs
new file mode 100644
index 00000000..3dd66a6d
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/Mock/StardewValley/Object.cs
@@ -0,0 +1,12 @@
+// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
+using Netcode;
+
+namespace StardewValley
+{
+ /// <summary>A simplified version of Stardew Valley's <c>StardewValley.Object</c> class for unit testing.</summary>
+ public class Object : Item
+ {
+ /// <summary>A net int field with an equivalent non-net property.</summary>
+ public NetInt type = new NetInt { Value = 42 };
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
new file mode 100644
index 00000000..101f4c21
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/NetFieldAnalyzerTests.cs
@@ -0,0 +1,140 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using NUnit.Framework;
+using SMAPI.ModBuildConfig.Analyzer.Tests.Framework;
+using StardewModdingAPI.ModBuildConfig.Analyzer;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests
+{
+ /// <summary>Unit tests for <see cref="NetFieldAnalyzer"/>.</summary>
+ [TestFixture]
+ public class NetFieldAnalyzerTests : DiagnosticVerifier
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.</summary>
+ const string SampleProgram = @"
+ using System;
+ using StardewValley;
+ using Netcode;
+ using SObject = StardewValley.Object;
+
+ namespace SampleMod
+ {
+ class ModEntry
+ {
+ public void Entry()
+ {
+ {{test-code}}
+ }
+ }
+ }
+ ";
+
+ /// <summary>The line number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
+ private const int SampleCodeLine = 13;
+
+ /// <summary>The column number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
+ private const int SampleCodeColumn = 25;
+
+
+ /*********
+ ** Unit tests
+ *********/
+ /// <summary>Test that no diagnostics are raised for an empty code block.</summary>
+ [TestCase]
+ public void EmptyCode_HasNoDiagnostics()
+ {
+ // arrange
+ string test = @"";
+
+ // assert
+ this.VerifyCSharpDiagnostic(test);
+ }
+
+ /// <summary>Test that the expected diagnostic message is raised for implicit net field comparisons.</summary>
+ /// <param name="codeText">The code line to test.</param>
+ /// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
+ /// <param name="expression">The expression which should be reported.</param>
+ /// <param name="fromType">The source type name which should be reported.</param>
+ /// <param name="toType">The target type name which should be reported.</param>
+ [TestCase("Item item = null; if (item.netIntField < 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField <= 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField > 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField >= 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField == 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntField != 42);", 22, "item.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item?.netIntField != 42);", 22, "item?.netIntField", "NetInt", "int")]
+ [TestCase("Item item = null; if (item?.netIntField != null);", 22, "item?.netIntField", "NetInt", "object")]
+ [TestCase("Item item = null; if (item.netIntProperty < 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntProperty <= 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntProperty > 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntProperty >= 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntProperty == 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item.netIntProperty != 42);", 22, "item.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item?.netIntProperty != 42);", 22, "item?.netIntProperty", "NetInt", "int")]
+ [TestCase("Item item = null; if (item?.netIntProperty != null);", 22, "item?.netIntProperty", "NetInt", "object")]
+ [TestCase("Item item = null; if (item.netRefField == null);", 22, "item.netRefField", "NetRef", "object")]
+ [TestCase("Item item = null; if (item.netRefField != null);", 22, "item.netRefField", "NetRef", "object")]
+ [TestCase("Item item = null; if (item.netRefProperty == null);", 22, "item.netRefProperty", "NetRef", "object")]
+ [TestCase("Item item = null; if (item.netRefProperty != null);", 22, "item.netRefProperty", "NetRef", "object")]
+ [TestCase("SObject obj = null; if (obj.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ same as above, but inherited from base class
+ [TestCase("SObject obj = null; if (obj.netIntProperty != 42);", 24, "obj.netIntProperty", "NetInt", "int")]
+ [TestCase("SObject obj = null; if (obj.netRefField == null);", 24, "obj.netRefField", "NetRef", "object")]
+ [TestCase("SObject obj = null; if (obj.netRefField != null);", 24, "obj.netRefField", "NetRef", "object")]
+ [TestCase("SObject obj = null; if (obj.netRefProperty == null);", 24, "obj.netRefProperty", "NetRef", "object")]
+ [TestCase("SObject obj = null; if (obj.netRefProperty != null);", 24, "obj.netRefProperty", "NetRef", "object")]
+ public void AvoidImplicitNetFieldComparisons_RaisesDiagnostic(string codeText, int column, string expression, string fromType, string toType)
+ {
+ // arrange
+ string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
+ DiagnosticResult expected = new DiagnosticResult
+ {
+ Id = "SMAPI001",
+ Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
+ Severity = DiagnosticSeverity.Warning,
+ Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
+ };
+
+ // assert
+ this.VerifyCSharpDiagnostic(code, expected);
+ }
+
+ /// <summary>Test that the expected diagnostic message is raised for avoidable net field references.</summary>
+ /// <param name="codeText">The code line to test.</param>
+ /// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
+ /// <param name="expression">The expression which should be reported.</param>
+ /// <param name="netType">The net type name which should be reported.</param>
+ /// <param name="suggestedProperty">The suggested property name which should be reported.</param>
+ [TestCase("Item item = null; int category = item.category;", 33, "item.category", "NetInt", "Category")]
+ [TestCase("Item item = null; int category = (item).category;", 33, "(item).category", "NetInt", "Category")]
+ [TestCase("Item item = null; int category = ((Item)item).category;", 33, "((Item)item).category", "NetInt", "Category")]
+ [TestCase("SObject obj = null; int category = obj.category;", 35, "obj.category", "NetInt", "Category")]
+ public void AvoidNetFields_RaisesDiagnostic(string codeText, int column, string expression, string netType, string suggestedProperty)
+ {
+ // arrange
+ string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
+ DiagnosticResult expected = new DiagnosticResult
+ {
+ Id = "SMAPI002",
+ Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
+ Severity = DiagnosticSeverity.Warning,
+ Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
+ };
+
+ // assert
+ this.VerifyCSharpDiagnostic(code, expected);
+ }
+
+
+ /*********
+ ** Helpers
+ *********/
+ /// <summary>Get the analyzer being tested.</summary>
+ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ {
+ return new NetFieldAnalyzer();
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs
new file mode 100644
index 00000000..dc7476ef
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/ObsoleteFieldAnalyzerTests.cs
@@ -0,0 +1,88 @@
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.Diagnostics;
+using NUnit.Framework;
+using SMAPI.ModBuildConfig.Analyzer.Tests.Framework;
+using StardewModdingAPI.ModBuildConfig.Analyzer;
+
+namespace SMAPI.ModBuildConfig.Analyzer.Tests
+{
+ /// <summary>Unit tests for <see cref="ObsoleteFieldAnalyzer"/>.</summary>
+ [TestFixture]
+ public class ObsoleteFieldAnalyzerTests : DiagnosticVerifier
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Sample C# mod code, with a {{test-code}} placeholder for the code in the Entry method to test.</summary>
+ const string SampleProgram = @"
+ using System;
+ using StardewValley;
+ using Netcode;
+ using SObject = StardewValley.Object;
+
+ namespace SampleMod
+ {
+ class ModEntry
+ {
+ public void Entry()
+ {
+ {{test-code}}
+ }
+ }
+ }
+ ";
+
+ /// <summary>The line number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
+ private const int SampleCodeLine = 13;
+
+ /// <summary>The column number where the unit tested code is injected into <see cref="SampleProgram"/>.</summary>
+ private const int SampleCodeColumn = 25;
+
+
+ /*********
+ ** Unit tests
+ *********/
+ /// <summary>Test that no diagnostics are raised for an empty code block.</summary>
+ [TestCase]
+ public void EmptyCode_HasNoDiagnostics()
+ {
+ // arrange
+ string test = @"";
+
+ // assert
+ this.VerifyCSharpDiagnostic(test);
+ }
+
+ /// <summary>Test that the expected diagnostic message is raised for an obsolete field reference.</summary>
+ /// <param name="codeText">The code line to test.</param>
+ /// <param name="column">The column within the code line where the diagnostic message should be reported.</param>
+ /// <param name="oldName">The old field name which should be reported.</param>
+ /// <param name="newName">The new field name which should be reported.</param>
+ [TestCase("var x = new Farmer().friendships;", 8, "StardewValley.Farmer.friendships", "friendshipData")]
+ public void AvoidObsoleteField_RaisesDiagnostic(string codeText, int column, string oldName, string newName)
+ {
+ // arrange
+ string code = ObsoleteFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
+ DiagnosticResult expected = new DiagnosticResult
+ {
+ Id = "SMAPI003",
+ Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/smapi003 for details.",
+ Severity = DiagnosticSeverity.Warning,
+ Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) }
+ };
+
+ // assert
+ this.VerifyCSharpDiagnostic(code, expected);
+ }
+
+
+ /*********
+ ** Helpers
+ *********/
+ /// <summary>Get the analyzer being tested.</summary>
+ protected override DiagnosticAnalyzer GetCSharpDiagnosticAnalyzer()
+ {
+ return new ObsoleteFieldAnalyzer();
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
new file mode 100644
index 00000000..b5630314
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer.Tests/SMAPI.ModBuildConfig.Analyzer.Tests.csproj
@@ -0,0 +1,18 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netcoreapp2.0</TargetFramework>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" />
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
+ <PackageReference Include="NUnit" Version="3.10.1" />
+ <PackageReference Include="NUnit3TestAdapter" Version="3.10.0" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs
new file mode 100644
index 00000000..915a50e8
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/NetFieldAnalyzer.cs
@@ -0,0 +1,271 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace StardewModdingAPI.ModBuildConfig.Analyzer
+{
+ /// <summary>Detects implicit conversion from Stardew Valley's <c>Netcode</c> types. These have very unintuitive implicit conversion rules, so mod authors should always explicitly convert the type with appropriate null checks.</summary>
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class NetFieldAnalyzer : DiagnosticAnalyzer
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>The namespace for Stardew Valley's <c>Netcode</c> types.</summary>
+ private const string NetcodeNamespace = "Netcode";
+
+ /// <summary>Maps net fields to their equivalent non-net properties where available.</summary>
+ private readonly IDictionary<string, string> NetFieldWrapperProperties = new Dictionary<string, string>
+ {
+ // Character
+ ["StardewValley.Character::currentLocationRef"] = "currentLocation",
+ ["StardewValley.Character::facingDirection"] = "FacingDirection",
+ ["StardewValley.Character::name"] = "Name",
+ ["StardewValley.Character::position"] = "Position",
+ ["StardewValley.Character::scale"] = "Scale",
+ ["StardewValley.Character::speed"] = "Speed",
+ ["StardewValley.Character::sprite"] = "Sprite",
+
+ // Chest
+ ["StardewValley.Objects.Chest::tint"] = "Tint",
+
+ // Farmer
+ ["StardewValley.Farmer::houseUpgradeLevel"] = "HouseUpgradeLevel",
+ ["StardewValley.Farmer::isMale"] = "IsMale",
+ ["StardewValley.Farmer::items"] = "Items",
+ ["StardewValley.Farmer::magneticRadius"] = "MagneticRadius",
+ ["StardewValley.Farmer::stamina"] = "Stamina",
+ ["StardewValley.Farmer::uniqueMultiplayerID"] = "UniqueMultiplayerID",
+ ["StardewValley.Farmer::usingTool"] = "UsingTool",
+
+ // Forest
+ ["StardewValley.Locations.Forest::netTravelingMerchantDay"] = "travelingMerchantDay",
+ ["StardewValley.Locations.Forest::netLog"] = "log",
+
+ // FruitTree
+ ["StardewValley.TerrainFeatures.FruitTree::greenHouseTileTree"] = "GreenHouseTileTree",
+ ["StardewValley.TerrainFeatures.FruitTree::greenHouseTree"] = "GreenHouseTree",
+
+ // GameLocation
+ ["StardewValley.GameLocation::isFarm"] = "IsFarm",
+ ["StardewValley.GameLocation::isOutdoors"] = "IsOutdoors",
+ ["StardewValley.GameLocation::lightLevel"] = "LightLevel",
+ ["StardewValley.GameLocation::name"] = "Name",
+
+ // Item
+ ["StardewValley.Item::category"] = "Category",
+ ["StardewValley.Item::netName"] = "Name",
+ ["StardewValley.Item::parentSheetIndex"] = "ParentSheetIndex",
+ ["StardewValley.Item::specialVariable"] = "SpecialVariable",
+
+ // Junimo
+ ["StardewValley.Characters.Junimo::eventActor"] = "EventActor",
+
+ // LightSource
+ ["StardewValley.LightSource::identifier"] = "Identifier",
+
+ // Monster
+ ["StardewValley.Monsters.Monster::damageToFarmer"] = "DamageToFarmer",
+ ["StardewValley.Monsters.Monster::experienceGained"] = "ExperienceGained",
+ ["StardewValley.Monsters.Monster::health"] = "Health",
+ ["StardewValley.Monsters.Monster::maxHealth"] = "MaxHealth",
+ ["StardewValley.Monsters.Monster::netFocusedOnFarmers"] = "focusedOnFarmers",
+ ["StardewValley.Monsters.Monster::netWildernessFarmMonster"] = "wildernessFarmMonster",
+ ["StardewValley.Monsters.Monster::slipperiness"] = "Slipperiness",
+
+ // NPC
+ ["StardewValley.NPC::age"] = "Age",
+ ["StardewValley.NPC::birthday_Day"] = "Birthday_Day",
+ ["StardewValley.NPC::birthday_Season"] = "Birthday_Season",
+ ["StardewValley.NPC::breather"] = "Breather",
+ ["StardewValley.NPC::defaultMap"] = "DefaultMap",
+ ["StardewValley.NPC::gender"] = "Gender",
+ ["StardewValley.NPC::hideShadow"] = "HideShadow",
+ ["StardewValley.NPC::isInvisible"] = "IsInvisible",
+ ["StardewValley.NPC::isWalkingTowardPlayer"] = "IsWalkingTowardPlayer",
+ ["StardewValley.NPC::manners"] = "Manners",
+ ["StardewValley.NPC::optimism"] = "Optimism",
+ ["StardewValley.NPC::socialAnxiety"] = "SocialAnxiety",
+
+ // Object
+ ["StardewValley.Object::canBeGrabbed"] = "CanBeGrabbed",
+ ["StardewValley.Object::canBeSetDown"] = "CanBeSetDown",
+ ["StardewValley.Object::edibility"] = "Edibility",
+ ["StardewValley.Object::flipped"] = "Flipped",
+ ["StardewValley.Object::fragility"] = "Fragility",
+ ["StardewValley.Object::hasBeenPickedUpByFarmer"] = "HasBeenPickedUpByFarmer",
+ ["StardewValley.Object::isHoedirt"] = "IsHoeDirt",
+ ["StardewValley.Object::isOn"] = "IsOn",
+ ["StardewValley.Object::isRecipe"] = "IsRecipe",
+ ["StardewValley.Object::isSpawnedObject"] = "IsSpawnedObject",
+ ["StardewValley.Object::minutesUntilReady"] = "MinutesUntilReady",
+ ["StardewValley.Object::netName"] = "name",
+ ["StardewValley.Object::price"] = "Price",
+ ["StardewValley.Object::quality"] = "Quality",
+ ["StardewValley.Object::scale"] = "Scale",
+ ["StardewValley.Object::stack"] = "Stack",
+ ["StardewValley.Object::tileLocation"] = "TileLocation",
+ ["StardewValley.Object::type"] = "Type",
+
+ // Projectile
+ ["StardewValley.Projectiles.Projectile::ignoreLocationCollision"] = "IgnoreLocationCollision",
+
+ // Tool
+ ["StardewValley.Tool::currentParentTileIndex"] = "CurrentParentTileIndex",
+ ["StardewValley.Tool::indexOfMenuItemView"] = "IndexOfMenuItemView",
+ ["StardewValley.Tool::initialParentTileIndex"] = "InitialParentTileIndex",
+ ["StardewValley.Tool::instantUse"] = "InstantUse",
+ ["StardewValley.Tool::netName"] = "BaseName",
+ ["StardewValley.Tool::stackable"] = "Stackable",
+ ["StardewValley.Tool::upgradeLevel"] = "UpgradeLevel"
+ };
+
+ /// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
+ private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
+ {
+ ["SMAPI001"] = new DiagnosticDescriptor(
+ id: "SMAPI001",
+ title: "Netcode types shouldn't be implicitly converted",
+ messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/smapi001 for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/buildmsg/smapi001"
+ ),
+ ["SMAPI002"] = new DiagnosticDescriptor(
+ id: "SMAPI002",
+ title: "Avoid Netcode types when possible",
+ messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/smapi002 for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/buildmsg/smapi001"
+ )
+ };
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The descriptors for the diagnostics that this analyzer is capable of producing.</summary>
+ public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public NetFieldAnalyzer()
+ {
+ this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
+ }
+
+ /// <summary>Called once at session start to register actions in the analysis context.</summary>
+ /// <param name="context">The analysis context.</param>
+ public override void Initialize(AnalysisContext context)
+ {
+ // SMAPI002: avoid net fields if possible
+ context.RegisterSyntaxNodeAction(
+ this.AnalyzeAvoidableNetField,
+ SyntaxKind.SimpleMemberAccessExpression
+ );
+
+ // SMAPI001: avoid implicit net field conversion
+ context.RegisterSyntaxNodeAction(
+ this.AnalyseNetFieldConversions,
+ SyntaxKind.EqualsExpression,
+ SyntaxKind.NotEqualsExpression,
+ SyntaxKind.GreaterThanExpression,
+ SyntaxKind.GreaterThanOrEqualExpression,
+ SyntaxKind.LessThanExpression,
+ SyntaxKind.LessThanOrEqualExpression
+ );
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Analyse a syntax node and add a diagnostic message if it references a net field when there's a non-net equivalent available.</summary>
+ /// <param name="context">The analysis context.</param>
+ private void AnalyzeAvoidableNetField(SyntaxNodeAnalysisContext context)
+ {
+ try
+ {
+ // check member type
+ MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
+ TypeInfo memberType = context.SemanticModel.GetTypeInfo(node);
+ if (!this.IsNetType(memberType.Type))
+ return;
+
+ // get reference info
+ ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
+ string propertyName = node.Name.Identifier.Text;
+
+ // suggest replacement
+ for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
+ {
+ if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{propertyName}", out string suggestedPropertyName))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI002"], context.Node.GetLocation(), node, memberType.Type.Name, suggestedPropertyName));
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
+ }
+ }
+
+ /// <summary>Analyse a syntax node and add a diagnostic message if it implicitly converts a net field.</summary>
+ /// <param name="context">The analysis context.</param>
+ private void AnalyseNetFieldConversions(SyntaxNodeAnalysisContext context)
+ {
+ try
+ {
+ BinaryExpressionSyntax binaryExpression = (BinaryExpressionSyntax)context.Node;
+ foreach (var pair in new[] { Tuple.Create(binaryExpression.Left, binaryExpression.Right), Tuple.Create(binaryExpression.Right, binaryExpression.Left) })
+ {
+ // get node info
+ ExpressionSyntax curExpression = pair.Item1; // the side of the comparison being examined
+ ExpressionSyntax otherExpression = pair.Item2; // the other side
+ TypeInfo typeInfo = context.SemanticModel.GetTypeInfo(curExpression);
+ if (!this.IsNetType(typeInfo.Type))
+ continue;
+
+ // warn for implicit conversion
+ if (!this.IsNetType(typeInfo.ConvertedType))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, typeInfo.ConvertedType));
+ break;
+ }
+
+ // warn for comparison to null
+ // An expression like `building.indoors != null` will sometimes convert `building.indoors` to NetFieldBase instead of object before comparison. Haven't reproduced this in unit tests yet.
+ Optional<object> otherValue = context.SemanticModel.GetConstantValue(otherExpression);
+ if (otherValue.HasValue && otherValue.Value == null)
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI001"], context.Node.GetLocation(), curExpression, typeInfo.Type.Name, "null"));
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
+ }
+ }
+
+ /// <summary>Get whether a type symbol references a <c>Netcode</c> type.</summary>
+ /// <param name="typeSymbol">The type symbol.</param>
+ private bool IsNetType(ITypeSymbol typeSymbol)
+ {
+ return typeSymbol?.ContainingNamespace?.Name == NetFieldAnalyzer.NetcodeNamespace;
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs
new file mode 100644
index 00000000..00565329
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/ObsoleteFieldAnalyzer.cs
@@ -0,0 +1,98 @@
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Diagnostics;
+
+namespace StardewModdingAPI.ModBuildConfig.Analyzer
+{
+ /// <summary>Detects references to a field which has been replaced.</summary>
+ [DiagnosticAnalyzer(LanguageNames.CSharp)]
+ public class ObsoleteFieldAnalyzer : DiagnosticAnalyzer
+ {
+ /*********
+ ** Properties
+ *********/
+ /// <summary>Maps obsolete fields/properties to their non-obsolete equivalent.</summary>
+ private readonly IDictionary<string, string> ReplacedFields = new Dictionary<string, string>
+ {
+ // Farmer
+ ["StardewValley.Farmer::friendships"] = "friendshipData"
+ };
+
+ /// <summary>Describes the diagnostic rule covered by the analyzer.</summary>
+ private readonly IDictionary<string, DiagnosticDescriptor> Rules = new Dictionary<string, DiagnosticDescriptor>
+ {
+ ["SMAPI003"] = new DiagnosticDescriptor(
+ id: "SMAPI003",
+ title: "Reference to obsolete field",
+ messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/smapi003 for details.",
+ category: "SMAPI.CommonErrors",
+ defaultSeverity: DiagnosticSeverity.Warning,
+ isEnabledByDefault: true,
+ helpLinkUri: "https://smapi.io/buildmsg/smapi003"
+ )
+ };
+
+
+ /*********
+ ** Accessors
+ *********/
+ /// <summary>The descriptors for the diagnostics that this analyzer is capable of producing.</summary>
+ public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
+
+
+ /*********
+ ** Public methods
+ *********/
+ /// <summary>Construct an instance.</summary>
+ public ObsoleteFieldAnalyzer()
+ {
+ this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
+ }
+
+ /// <summary>Called once at session start to register actions in the analysis context.</summary>
+ /// <param name="context">The analysis context.</param>
+ public override void Initialize(AnalysisContext context)
+ {
+ // SMAPI003: avoid obsolete fields
+ context.RegisterSyntaxNodeAction(
+ this.AnalyzeObsoleteFields,
+ SyntaxKind.SimpleMemberAccessExpression
+ );
+ }
+
+
+ /*********
+ ** Private methods
+ *********/
+ /// <summary>Analyse a syntax node and add a diagnostic message if it references an obsolete field.</summary>
+ /// <param name="context">The analysis context.</param>
+ private void AnalyzeObsoleteFields(SyntaxNodeAnalysisContext context)
+ {
+ try
+ {
+ // get reference info
+ MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
+ ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
+ string propertyName = node.Name.Identifier.Text;
+
+ // suggest replacement
+ for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
+ {
+ if (this.ReplacedFields.TryGetValue($"{type}::{propertyName}", out string replacement))
+ {
+ context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI003"], context.Node.GetLocation(), $"{type}.{propertyName}", replacement));
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs b/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs
new file mode 100644
index 00000000..1cc41000
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/Properties/AssemblyInfo.cs
@@ -0,0 +1,4 @@
+using System.Reflection;
+
+[assembly: AssemblyTitle("SMAPI.ModBuildConfig.Analyzer")]
+[assembly: AssemblyDescription("")]
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj b/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj
new file mode 100644
index 00000000..c32343e3
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/StardewModdingAPI.ModBuildConfig.Analyzer.csproj
@@ -0,0 +1,23 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>netstandard1.3</TargetFramework>
+ <GenerateAssemblyInfo>false</GenerateAssemblyInfo>
+ <IncludeBuildOutput>false</IncludeBuildOutput>
+ <OutputPath>bin</OutputPath>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <Compile Include="..\..\build\GlobalAssemblyInfo.cs" Link="Properties\GlobalAssemblyInfo.cs" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" PrivateAssets="all" />
+ <PackageReference Update="NETStandard.Library" PrivateAssets="all" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
+ </ItemGroup>
+
+</Project>
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1 b/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1
new file mode 100644
index 00000000..ff051759
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/tools/install.ps1
@@ -0,0 +1,58 @@
+param($installPath, $toolsPath, $package, $project)
+
+if($project.Object.SupportsPackageDependencyResolution)
+{
+ if($project.Object.SupportsPackageDependencyResolution())
+ {
+ # Do not install analyzers via install.ps1, instead let the project system handle it.
+ return
+ }
+}
+
+$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve
+
+foreach($analyzersPath in $analyzersPaths)
+{
+ if (Test-Path $analyzersPath)
+ {
+ # Install the language agnostic analyzers.
+ foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll)
+ {
+ if($project.Object.AnalyzerReferences)
+ {
+ $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
+ }
+ }
+ }
+}
+
+# $project.Type gives the language name like (C# or VB.NET)
+$languageFolder = ""
+if($project.Type -eq "C#")
+{
+ $languageFolder = "cs"
+}
+if($project.Type -eq "VB.NET")
+{
+ $languageFolder = "vb"
+}
+if($languageFolder -eq "")
+{
+ return
+}
+
+foreach($analyzersPath in $analyzersPaths)
+{
+ # Install language specific analyzers.
+ $languageAnalyzersPath = join-path $analyzersPath $languageFolder
+ if (Test-Path $languageAnalyzersPath)
+ {
+ foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll)
+ {
+ if($project.Object.AnalyzerReferences)
+ {
+ $project.Object.AnalyzerReferences.Add($analyzerFilePath.FullName)
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1 b/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1
new file mode 100644
index 00000000..4bed3337
--- /dev/null
+++ b/src/SMAPI.ModBuildConfig.Analyzer/tools/uninstall.ps1
@@ -0,0 +1,65 @@
+param($installPath, $toolsPath, $package, $project)
+
+if($project.Object.SupportsPackageDependencyResolution)
+{
+ if($project.Object.SupportsPackageDependencyResolution())
+ {
+ # Do not uninstall analyzers via uninstall.ps1, instead let the project system handle it.
+ return
+ }
+}
+
+$analyzersPaths = Join-Path (Join-Path (Split-Path -Path $toolsPath -Parent) "analyzers") * -Resolve
+
+foreach($analyzersPath in $analyzersPaths)
+{
+ # Uninstall the language agnostic analyzers.
+ if (Test-Path $analyzersPath)
+ {
+ foreach ($analyzerFilePath in Get-ChildItem -Path "$analyzersPath\*.dll" -Exclude *.resources.dll)
+ {
+ if($project.Object.AnalyzerReferences)
+ {
+ $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName)
+ }
+ }
+ }
+}
+
+# $project.Type gives the language name like (C# or VB.NET)
+$languageFolder = ""
+if($project.Type -eq "C#")
+{
+ $languageFolder = "cs"
+}
+if($project.Type -eq "VB.NET")
+{
+ $languageFolder = "vb"
+}
+if($languageFolder -eq "")
+{
+ return
+}
+
+foreach($analyzersPath in $analyzersPaths)
+{
+ # Uninstall language specific analyzers.
+ $languageAnalyzersPath = join-path $analyzersPath $languageFolder
+ if (Test-Path $languageAnalyzersPath)
+ {
+ foreach ($analyzerFilePath in Get-ChildItem -Path "$languageAnalyzersPath\*.dll" -Exclude *.resources.dll)
+ {
+ if($project.Object.AnalyzerReferences)
+ {
+ try
+ {
+ $project.Object.AnalyzerReferences.Remove($analyzerFilePath.FullName)
+ }
+ catch
+ {
+
+ }
+ }
+ }
+ }
+}
diff --git a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
index d2e06e05..2e3ba356 100644
--- a/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
+++ b/src/SMAPI.ModBuildConfig/StardewModdingAPI.ModBuildConfig.csproj
@@ -57,4 +57,5 @@
</ItemGroup>
<Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
+ <Import Project="..\..\build\prepare-nuget-package.targets" />
</Project> \ No newline at end of file
diff --git a/src/SMAPI.ModBuildConfig/build/smapi.targets b/src/SMAPI.ModBuildConfig/build/smapi.targets
index d2e37101..a177840c 100644
--- a/src/SMAPI.ModBuildConfig/build/smapi.targets
+++ b/src/SMAPI.ModBuildConfig/build/smapi.targets
@@ -142,7 +142,7 @@
<Target Name="BeforeBuild">
<Error Condition="'$(OS)' != 'OSX' AND '$(OS)' != 'Unix' AND '$(OS)' != 'Windows_NT'" Text="The mod build package doesn't recognise OS type '$(OS)'." />
- <Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see details at https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#game-path." />
+ <Error Condition="!Exists('$(GamePath)')" Text="The mod build package can't find your game folder. You can specify where to find it; see https://smapi.io/buildmsg/game-path." />
<Error Condition="'$(OS)' == 'Windows_NT' AND !Exists('$(GamePath)\Stardew Valley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the Stardew Valley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." />
<Error Condition="'$(OS)' != 'Windows_NT' AND !Exists('$(GamePath)\StardewValley.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain the StardewValley.exe file. If this folder is invalid, delete it and the package will autodetect another game install path." />
<Error Condition="!Exists('$(GamePath)\StardewModdingAPI.exe')" Text="The mod build package found a game folder at $(GamePath), but it doesn't contain SMAPI. You need to install SMAPI before building the mod." />
diff --git a/src/SMAPI.ModBuildConfig/package.nuspec b/src/SMAPI.ModBuildConfig/package.nuspec
index d24e15be..92e7e81e 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-alpha20180325</version>
+ <version>2.1-alpha20180410</version>
<title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors>
<owners>Pathoschild</owners>
@@ -12,28 +12,10 @@
<iconUrl>https://raw.githubusercontent.com/Pathoschild/SMAPI/develop/src/SMAPI.ModBuildConfig/assets/nuget-icon.png</iconUrl>
<description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods.</description>
<releaseNotes>
- 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).
- - Added: mod deploy and release zips now exclude Json.NET automatically, since it's provided by SMAPI.
- - Added mod's version to release zip filename.
- - Improved errors to simplify troubleshooting.
- - Fixed release zip not having a mod folder.
- - Fixed release zip failing if mod name contains characters that aren't valid in a filename.
-
- 2.0.1:
- - Fixed mod deploy failing to create subfolders if they don't already exist.
-
- 2.0.2:
- - Fixed compatibility issue on Linux.
-
- 2.0.3:
+ 2.1:
- Added support for Stardew Valley 1.3.
- Added support for unit test projects.
+ - Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
</releaseNotes>
</metadata>
- <files>
- <file src="build/smapi.targets" target="build/Pathoschild.Stardew.ModBuildConfig.targets" />
- <file src="bin/StardewModdingAPI.ModBuildConfig.dll" target="build/StardewModdingAPI.ModBuildConfig.dll" />
- </files>
</package>
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index a56cf66d..b6f9b81c 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.4",
+ "Version": "2.5.5",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll"
diff --git a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
index e4b2c8c6..0c793817 100644
--- a/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
+++ b/src/SMAPI.Tests/StardewModdingAPI.Tests.csproj
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+ <Import Project="..\packages\NUnit.3.10.1\build\NUnit.props" Condition="Exists('..\packages\NUnit.3.10.1\build\NUnit.props')" />
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
@@ -33,17 +34,23 @@
<Reference Include="Castle.Core, Version=4.0.0.0, Culture=neutral, PublicKeyToken=407dd0808d44fbdc, processorArchitecture=MSIL">
<HintPath>..\packages\Castle.Core.4.2.1\lib\net45\Castle.Core.dll</HintPath>
</Reference>
- <Reference Include="Moq, Version=4.7.142.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL">
- <HintPath>..\packages\Moq.4.7.142\lib\net45\Moq.dll</HintPath>
+ <Reference Include="Moq, Version=4.8.0.0, Culture=neutral, PublicKeyToken=69f491c39445e920, processorArchitecture=MSIL">
+ <HintPath>..\packages\Moq.4.8.2\lib\net45\Moq.dll</HintPath>
</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>
</Reference>
- <Reference Include="nunit.framework, Version=3.8.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL">
- <HintPath>..\packages\NUnit.3.8.1\lib\net45\nunit.framework.dll</HintPath>
+ <Reference Include="nunit.framework, Version=3.10.1.0, Culture=neutral, PublicKeyToken=2638cd05610744eb, processorArchitecture=MSIL">
+ <HintPath>..\packages\NUnit.3.10.1\lib\net45\nunit.framework.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
+ <Reference Include="System.Threading.Tasks.Extensions, Version=4.1.1.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+ <HintPath>..\packages\System.Threading.Tasks.Extensions.4.4.0\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll</HintPath>
+ </Reference>
+ <Reference Include="System.ValueTuple, Version=4.0.2.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
+ <HintPath>..\packages\System.ValueTuple.4.4.0\lib\netstandard1.0\System.ValueTuple.dll</HintPath>
+ </Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
@@ -58,6 +65,7 @@
<Compile Include="Sample.cs" />
</ItemGroup>
<ItemGroup>
+ <None Include="app.config" />
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
@@ -66,6 +74,15 @@
<Name>StardewModdingAPI</Name>
</ProjectReference>
</ItemGroup>
+ <ItemGroup>
+ <Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />
+ </ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
+ <Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
+ <PropertyGroup>
+ <ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
+ </PropertyGroup>
+ <Error Condition="!Exists('..\packages\NUnit.3.10.1\build\NUnit.props')" Text="$([System.String]::Format('$(ErrorText)', '..\packages\NUnit.3.10.1\build\NUnit.props'))" />
+ </Target>
</Project> \ No newline at end of file
diff --git a/src/SMAPI.Tests/app.config b/src/SMAPI.Tests/app.config
new file mode 100644
index 00000000..7d8c9227
--- /dev/null
+++ b/src/SMAPI.Tests/app.config
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<configuration>
+ <runtime>
+ <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
+ <dependentAssembly>
+ <assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
+ <bindingRedirect oldVersion="0.0.0.0-4.1.1.0" newVersion="4.1.1.0" />
+ </dependentAssembly>
+ </assemblyBinding>
+ </runtime>
+</configuration> \ No newline at end of file
diff --git a/src/SMAPI.Tests/packages.config b/src/SMAPI.Tests/packages.config
index 498325d6..bad26cfa 100644
--- a/src/SMAPI.Tests/packages.config
+++ b/src/SMAPI.Tests/packages.config
@@ -1,7 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Castle.Core" version="4.2.1" targetFramework="net45" />
- <package id="Moq" version="4.7.142" targetFramework="net45" />
- <package id="Newtonsoft.Json" version="11.0.1-beta3" targetFramework="net45" />
- <package id="NUnit" version="3.8.1" targetFramework="net45" />
+ <package id="Moq" version="4.8.2" targetFramework="net45" />
+ <package id="Newtonsoft.Json" version="11.0.2" targetFramework="net45" />
+ <package id="NUnit" version="3.10.1" targetFramework="net45" />
+ <package id="System.Threading.Tasks.Extensions" version="4.4.0" targetFramework="net45" />
+ <package id="System.ValueTuple" version="4.4.0" targetFramework="net45" />
</packages> \ No newline at end of file
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
index 4abe0737..2cfc6903 100644
--- a/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/GitHub/GitHubClient.cs
@@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.GitHub
GitRelease[] results = await this.Client
.GetAsync(string.Format(this.AnyReleaseUrlFormat, repo))
.AsArray<GitRelease>();
- return results.FirstOrDefault();
+ return results.FirstOrDefault(p => !p.IsDraft);
}
return await this.Client
diff --git a/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs b/src/SMAPI.Web/Framework/Clients/GitHub/GitRelease.cs
index 827374fb..d0db5297 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 draft version.</summary>
+ [JsonProperty("draft")]
+ public bool IsDraft { get; set; }
+
/// <summary>Whether this is a prerelease version.</summary>
[JsonProperty("prerelease")]
public bool IsPrerelease { get; set; }
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 9e44f163..f49fb05c 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -135,6 +135,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
{
Match match = this.ModPathPattern.Match(message.Text);
log.ModPath = match.Groups["path"].Value;
+ log.GamePath = new FileInfo(log.ModPath).Directory.FullName;
}
// log UTC timestamp line
diff --git a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
index a82b6a1b..87b20eb0 100644
--- a/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/Models/ParsedLog.cs
@@ -32,6 +32,9 @@ namespace StardewModdingAPI.Web.Framework.LogParsing.Models
/// <summary>The player's operating system.</summary>
public string OperatingSystem { get; set; }
+ /// <summary>The game install path.</summary>
+ public string GamePath { get; set; }
+
/// <summary>The mod folder path.</summary>
public string ModPath { get; set; }
diff --git a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
index 0719e311..4bae0b4c 100644
--- a/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
+++ b/src/SMAPI.Web/Framework/RewriteRules/RedirectToUrlRule.cs
@@ -12,11 +12,8 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
/*********
** Properties
*********/
- /// <summary>A predicate which indicates when the rule should be applied.</summary>
- private readonly Func<HttpRequest, bool> ShouldRewrite;
-
- /// <summary>The new URL to which to redirect.</summary>
- private readonly string NewUrl;
+ /// <summary>Get the new URL to which to redirect (or <c>null</c> to skip).</summary>
+ private readonly Func<HttpRequest, string> NewUrl;
/*********
@@ -27,8 +24,7 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
/// <param name="url">The new URL to which to redirect.</param>
public RedirectToUrlRule(Func<HttpRequest, bool> shouldRewrite, string url)
{
- this.ShouldRewrite = shouldRewrite ?? (req => true);
- this.NewUrl = url;
+ this.NewUrl = req => shouldRewrite(req) ? url : null;
}
/// <summary>Construct an instance.</summary>
@@ -37,8 +33,7 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
public RedirectToUrlRule(string pathRegex, string url)
{
Regex regex = new Regex(pathRegex, RegexOptions.IgnoreCase | RegexOptions.Compiled);
- this.ShouldRewrite = req => req.Path.HasValue && regex.IsMatch(req.Path.Value);
- this.NewUrl = url;
+ this.NewUrl = req => req.Path.HasValue ? regex.Replace(req.Path.Value, url) : null;
}
/// <summary>Applies the rule. Implementations of ApplyRule should set the value for <see cref="RewriteContext.Result" /> (defaults to RuleResult.ContinueRules).</summary>
@@ -47,14 +42,15 @@ namespace StardewModdingAPI.Web.Framework.RewriteRules
{
HttpRequest request = context.HttpContext.Request;
- // check condition
- if (!this.ShouldRewrite(request))
+ // check rewrite
+ string newUrl = this.NewUrl(request);
+ if (newUrl == null || newUrl == request.Path.Value)
return;
// redirect request
HttpResponse response = context.HttpContext.Response;
response.StatusCode = (int)HttpStatusCode.Redirect;
- response.Headers["Location"] = this.NewUrl;
+ response.Headers["Location"] = newUrl;
context.Result = RuleResult.EndResponse;
}
}
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 47102e5c..6c7ccecd 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -155,6 +155,7 @@ namespace StardewModdingAPI.Web
// shortcut redirects
redirects.Add(new RedirectToUrlRule(@"^/compat\.?$", "https://stardewvalleywiki.com/Modding:SMAPI_compatibility"));
redirects.Add(new RedirectToUrlRule(@"^/docs\.?$", "https://stardewvalleywiki.com/Modding:Index"));
+ redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
// redirect legacy canimod.com URLs
var wikiRedirects = new Dictionary<string, string[]>
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 7213e286..2d1c1b44 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -44,32 +44,53 @@
** Intro
*********@
<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p>
-<input type="button" id="upload-button" value="Share a new log" />
@if (Model.ParsedLog?.IsValid == true)
{
- <h2>Parsed log</h2>
+ <div class="banner success" v-pre>
+ <strong>The log was uploaded successfully!</strong><br/>
+ Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br/>
+ (Or <a id="upload-button" href="#">upload a new log</a>.)
+ </div>
+}
+else if (Model.ParsedLog?.IsValid == false)
+{
+ <div class="banner error" v-pre>
+ <strong>Oops, couldn't parse that file. (Make sure you upload the log file, not the console text.)</strong><br />
+ Share this URL when asking for help: <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
+ (Or <a id="upload-button" href="#">upload a new log</a>.)<br />
+ <br />
+ <small v-pre>Error details: @Model.ParsedLog.Error</small>
+ </div>
+}
+else
+{
+ <input type="button" id="upload-button" value="Share a new log" />
+}
+
+@*********
+** Parsed log
+*********@
+@if (Model.ParsedLog?.IsValid == true)
+{
+ <h2>Log info</h2>
<div id="output">
<table id="metadata">
<caption>Game info:</caption>
<tr>
- <td>SMAPI version:</td>
- <td v-pre>@Model.ParsedLog.ApiVersion</td>
- </tr>
- <tr>
- <td>Game version:</td>
- <td v-pre>@Model.ParsedLog.GameVersion</td>
+ <th>Stardew Valley:</th>
+ <td v-pre>@Model.ParsedLog.GameVersion on @Model.ParsedLog.OperatingSystem</td>
</tr>
<tr>
- <td>Platform:</td>
- <td v-pre>@Model.ParsedLog.OperatingSystem</td>
+ <th>SMAPI:</th>
+ <td v-pre>@Model.ParsedLog.ApiVersion</td>
</tr>
<tr>
- <td>Mods path:</td>
- <td v-pre>@Model.ParsedLog.ModPath</td>
+ <th>Folder:</th>
+ <td v-pre>@Model.ParsedLog.GamePath</td>
</tr>
<tr>
- <td>Log started:</td>
+ <th>Log started:</th>
<td>@Model.ParsedLog.Timestamp.UtcDateTime.ToString("yyyy-MM-dd HH:mm") UTC ({{localTimeStarted}} your time)</td>
</tr>
</table>
@@ -86,7 +107,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 v-pre>
- @mod.Name
+ <strong>@mod.Name</strong> @mod.Version
@if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList))
{
<div class="content-packs">
@@ -97,7 +118,6 @@
</div>
}
</td>
- <td v-pre>@mod.Version</td>
<td v-pre>@mod.Author</td>
@if (mod.Errors == 0)
{
@@ -148,12 +168,6 @@
}
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 v-pre>Error details: @Model.ParsedLog.Error</p>
- </div>
-
<h3>Raw log</h3>
<pre v-pre>@Model.ParsedLog.RawText</pre>
}
@@ -166,7 +180,6 @@ else if (Model.ParsedLog?.IsValid == false)
<li><a href="https://stardewvalleywiki.com/Modding:Player_FAQs#SMAPI_log" target="_blank">Find your SMAPI log file</a> (not the console text).</li>
<li>Drag the file onto the textbox below (or paste the text in).</li>
<li>Click <em>Parse</em>.</li>
- <li>Share the URL of the new page.</li>
</ol>
<textarea id="input" placeholder="Paste or drag the log here"></textarea>
<div class="buttons">
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index 03ca31ed..095707a8 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -25,7 +25,7 @@
"GitHubBaseUrl": "https://api.github.com",
"GitHubStableReleaseUrlFormat": "repos/{0}/releases/latest",
- "GitHubAnyReleaseUrlFormat": "repos/{0}/releases?per_page=1",
+ "GitHubAnyReleaseUrlFormat": "repos/{0}/releases?per_page=2", // allow for draft release (only visible if GitHub repo is owned by same account as the update check credentials)
"GitHubAcceptHeader": "application/vnd.github.v3+json",
"GitHubUsername": null, // see top note
"GitHubPassword": null, // see top note
diff --git a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
index cbf09ffe..789274e2 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/log-parser.css
@@ -20,27 +20,52 @@ caption {
font-family: monospace;
}
-#upload-button {
+input#upload-button {
background: #ccf;
border: 1px solid #000088;
}
-#upload-button {
+input#upload-button {
background: #eef;
}
+table caption {
+ font-weight: bold;
+}
+
+/*********
+** Result banner
+*********/
+.banner {
+ border: 2px solid gray;
+ border-radius: 5px;
+ padding: 1em;
+}
+
+.banner.success {
+ border-color: green;
+ background: #CFC;
+}
+
+.banner.error {
+ border-color: red;
+ background: #FCC;
+}
+
/*********
** Log metadata & filters
*********/
#metadata, #mods, #filters {
- font-weight: bold;
border-bottom: 1px dashed #888888;
- padding-bottom: 10px;
margin-bottom: 5px;
}
-table#metadata,
-table#mods {
+#metadata th {
+ text-align: right;
+ padding-right: 0.7em;
+}
+
+table#metadata, table#mods {
border: 1px solid #000000;
background: #ffffff;
border-radius: 5px;
@@ -115,6 +140,7 @@ table#mods {
#filters {
margin: 1em 0 0 0;
padding: 0;
+ font-weight: bold;
}
#filters span {
diff --git a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
index 38a75a80..c4a35e96 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/log-parser.js
@@ -92,7 +92,9 @@ smapi.logParser = function (data, sectionUrl) {
*********/
var error = $("#error");
- $("#upload-button").on("click", function() {
+ $("#upload-button").on("click", function(e) {
+ e.preventDefault();
+
$("#input").val("");
$("#popup-upload").fadeIn();
});
diff --git a/src/SMAPI.sln b/src/SMAPI.sln
index 6577b42e..56898a32 100644
--- a/src/SMAPI.sln
+++ b/src/SMAPI.sln
@@ -45,9 +45,17 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{09CF91E5
..\build\common.targets = ..\build\common.targets
..\build\GlobalAssemblyInfo.cs = ..\build\GlobalAssemblyInfo.cs
..\build\prepare-install-package.targets = ..\build\prepare-install-package.targets
+ ..\build\prepare-nuget-package.targets = ..\build\prepare-nuget-package.targets
EndProjectSection
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StardewModdingAPI.ModBuildConfig", "SMAPI.ModBuildConfig\StardewModdingAPI.ModBuildConfig.csproj", "{EA4F1E80-743F-4A1D-9757-AE66904A196A}"
+ ProjectSection(ProjectDependencies) = postProject
+ {80AD8528-AA49-4731-B4A6-C691845815A1} = {80AD8528-AA49-4731-B4A6-C691845815A1}
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StardewModdingAPI.ModBuildConfig.Analyzer", "SMAPI.ModBuildConfig.Analyzer\StardewModdingAPI.ModBuildConfig.Analyzer.csproj", "{80AD8528-AA49-4731-B4A6-C691845815A1}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SMAPI.ModBuildConfig.Analyzer.Tests", "SMAPI.ModBuildConfig.Analyzer.Tests\SMAPI.ModBuildConfig.Analyzer.Tests.csproj", "{0CF97929-B0D0-4D73-B7BF-4FF7191035F9}"
EndProject
Global
GlobalSection(SharedMSBuildProjectFiles) = preSolution
@@ -137,6 +145,30 @@ Global
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|Mixed Platforms.Build.0 = Release|x86
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.ActiveCfg = Release|x86
{EA4F1E80-743F-4A1D-9757-AE66904A196A}.Release|x86.Build.0 = Release|x86
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Debug|x86.Build.0 = Debug|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Any CPU.Build.0 = Release|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.ActiveCfg = Release|Any CPU
+ {80AD8528-AA49-4731-B4A6-C691845815A1}.Release|x86.Build.0 = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Debug|x86.Build.0 = Debug|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.ActiveCfg = Release|Any CPU
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -147,6 +179,7 @@ Global
{2AA02FB6-FF03-41CF-A215-2EE60AB4F5DC} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
{EB35A917-67B9-4EFA-8DFC-4FB49B3949BB} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
{09CF91E5-5BAB-4650-A200-E5EA9A633046} = {86C452BE-D2D8-45B4-B63F-E329EB06CEDA}
+ {0CF97929-B0D0-4D73-B7BF-4FF7191035F9} = {82D22ED7-A0A7-4D64-8E92-4B6A5E74ED11}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {70143042-A862-47A8-A677-7C819DDC90DC}
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 6270186a..1e35c030 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.4");
+ new SemanticVersion("2.5.5");
#endif
/// <summary>The minimum supported version of Stardew Valley.</summary>
@@ -186,7 +186,7 @@ namespace StardewModdingAPI
{
string prefix =
#if STARDEW_VALLEY_1_3
- new string(Game1.player.name.Value.Where(char.IsLetterOrDigit).ToArray());
+ new string(Game1.player.Name.Where(char.IsLetterOrDigit).ToArray());
#else
new string(Game1.player.name.Where(char.IsLetterOrDigit).ToArray());
#endif
diff --git a/src/SMAPI/Context.cs b/src/SMAPI/Context.cs
index 119e14c8..7ed9b052 100644
--- a/src/SMAPI/Context.cs
+++ b/src/SMAPI/Context.cs
@@ -1,4 +1,4 @@
-using StardewModdingAPI.Events;
+using StardewModdingAPI.Events;
using StardewValley;
using StardewValley.Menus;
@@ -25,6 +25,12 @@ namespace StardewModdingAPI
/// <summary>Whether the game is currently running the draw loop. This isn't relevant to most mods, since you should use <see cref="GraphicsEvents.OnPostRenderEvent"/> to draw to the screen.</summary>
public static bool IsInDrawLoop { get; internal set; }
+ /// <summary>Whether <see cref="IsWorldReady"/> and the player loaded the save in multiplayer mode (regardless of whether any other players are connected).</summary>
+ public static bool IsMultiplayer => Context.IsWorldReady && Game1.multiplayerMode != Game1.singlePlayer;
+
+ /// <summary>Whether <see cref="IsWorldReady"/> and the current player is the main player. This is always true in single-player, and true when hosting in multiplayer.</summary>
+ public static bool IsMainPlayer => Context.IsWorldReady && Game1.IsMasterGame;
+
/****
** Internal
****/
diff --git a/src/SMAPI/Framework/ContentCore.cs b/src/SMAPI/Framework/ContentCore.cs
index 3c7e7b5a..43357553 100644
--- a/src/SMAPI/Framework/ContentCore.cs
+++ b/src/SMAPI/Framework/ContentCore.cs
@@ -44,6 +44,11 @@ namespace StardewModdingAPI.Framework
/// <summary>The underlying asset cache.</summary>
private readonly ContentCache Cache;
+#if STARDEW_VALLEY_1_3
+ /// <summary>A lookup which indicates whether the asset is localisable (i.e. the filename contains the locale), if previously loaded.</summary>
+ private readonly IDictionary<string, bool> IsLocalisableLookup;
+#endif
+
/// <summary>The locale codes used in asset keys indexed by enum value.</summary>
private readonly IDictionary<LocalizedContentManager.LanguageCode, string> Locales;
@@ -106,6 +111,9 @@ namespace StardewModdingAPI.Framework
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);
+#if STARDEW_VALLEY_1_3
+ this.IsLocalisableLookup = reflection.GetField<IDictionary<string, bool>>(this.Content, "_localizedAsset").GetValue();
+#endif
}
/// <summary>Get a new content manager which defers loading to the content core.</summary>
@@ -512,8 +520,18 @@ namespace StardewModdingAPI.Framework
/// <param name="normalisedAssetName">The normalised asset name.</param>
private bool IsNormalisedKeyLoaded(string normalisedAssetName)
{
- return this.Cache.ContainsKey(normalisedAssetName)
+#if STARDEW_VALLEY_1_3
+ if (!this.IsLocalisableLookup.TryGetValue(normalisedAssetName, out bool localisable))
+ return false;
+
+ return localisable
+ ? this.Cache.ContainsKey($"{normalisedAssetName}.{this.Locales[this.Content.GetCurrentLanguage()]}")
+ : this.Cache.ContainsKey(normalisedAssetName);
+#else
+ return
+ this.Cache.ContainsKey(normalisedAssetName)
|| this.Cache.ContainsKey($"{normalisedAssetName}.{this.Locales[this.Content.GetCurrentLanguage()]}"); // translated asset
+#endif
}
/// <summary>Track that a content manager loaded an asset.</summary>
diff --git a/src/SMAPI/Framework/ModLoading/ModResolver.cs b/src/SMAPI/Framework/ModLoading/ModResolver.cs
index f878a1b9..a9896278 100644
--- a/src/SMAPI/Framework/ModLoading/ModResolver.cs
+++ b/src/SMAPI/Framework/ModLoading/ModResolver.cs
@@ -352,6 +352,7 @@ namespace StardewModdingAPI.Framework.ModLoading
{
// sorted successfully
case ModDependencyStatus.Sorted:
+ case ModDependencyStatus.Failed when !dependency.IsRequired: // ignore failed optional dependency
break;
// failed, which means this mod can't be loaded either
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 47bc40e6..c6e9aa92 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Xna.Framework;
@@ -25,6 +26,8 @@ using StardewValley.Tools;
using xTile.Dimensions;
#if !STARDEW_VALLEY_1_3
using xTile.Layers;
+#else
+using SFarmer = StardewValley.Farmer;
#endif
namespace StardewModdingAPI.Framework
@@ -133,8 +136,10 @@ namespace StardewModdingAPI.Framework
/// <summary>Whether this is the very first update tick since the game started.</summary>
private bool FirstUpdate;
+#if !STARDEW_VALLEY_1_3
/// <summary>The current game instance.</summary>
private static SGame Instance;
+#endif
/// <summary>A callback to invoke after the game finishes initialising.</summary>
private readonly Action OnGameInitialised;
@@ -145,6 +150,7 @@ namespace StardewModdingAPI.Framework
/// <summary>Simplifies access to private game code.</summary>
private static Reflector Reflection;
+#if !STARDEW_VALLEY_1_3
// ReSharper disable ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
/// <summary>Used to access private fields and methods.</summary>
private static List<float> _fpsList => SGame.Reflection.GetField<List<float>>(typeof(Game1), nameof(_fpsList)).GetValue();
@@ -160,11 +166,13 @@ namespace StardewModdingAPI.Framework
private readonly Action drawFarmBuildings = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawFarmBuildings)).Invoke();
private readonly Action drawHUD = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawHUD)).Invoke();
private readonly Action drawDialogueBox = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(drawDialogueBox)).Invoke();
-#if STARDEW_VALLEY_1_3
- private readonly Action<SpriteBatch> drawOverlays = spriteBatch => SGame.Reflection.GetMethod(SGame.Instance, nameof(SGame.drawOverlays)).Invoke(spriteBatch);
-#endif
private readonly Action renderScreenBuffer = () => SGame.Reflection.GetMethod(SGame.Instance, nameof(renderScreenBuffer)).Invoke();
// ReSharper restore ArrangeStaticMemberQualifier, ArrangeThisQualifier, InconsistentNaming
+#endif
+
+#if STARDEW_VALLEY_1_3
+ private static StringBuilder _debugStringBuilder => SGame.Reflection.GetField<StringBuilder>(typeof(Game1), nameof(_debugStringBuilder)).GetValue();
+#endif
/*********
@@ -191,7 +199,9 @@ namespace StardewModdingAPI.Framework
this.Monitor = monitor;
this.Events = eventManager;
this.FirstUpdate = true;
+#if !STARDEW_VALLEY_1_3
SGame.Instance = this;
+#endif
SGame.Reflection = reflection;
this.OnGameInitialised = onGameInitialised;
if (this.ContentCore == null) // shouldn't happen since CreateContentManager is called first, but let's init here just in case
@@ -252,7 +262,11 @@ namespace StardewModdingAPI.Framework
// a small chance that the task will finish after we defer but before the game checks,
// which means technically events should be raised, but the effects of missing one
// update tick are neglible and not worth the complications of bypassing Game1.Update.
+#if STARDEW_VALLEY_1_3
+ if (Game1._newDayTask != null)
+#else
if (SGame._newDayTask != null)
+#endif
{
base.Update(gameTime);
this.Events.Specialised_UnvalidatedUpdateTick.Raise();
@@ -565,7 +579,7 @@ namespace StardewModdingAPI.Framework
this.PreviousForagingLevel = Game1.player.foragingLevel;
this.PreviousMiningLevel = Game1.player.miningLevel;
this.PreviousLuckLevel = Game1.player.luckLevel;
- this.PreviousItems = Game1.player.items.Where(n => n != null).ToDictionary(n => n, n => n.Stack);
+ this.PreviousItems = Game1.player.items.Where(n => n != null).Distinct().ToDictionary(n => n, n => n.Stack);
this.PreviousLocationObjects = this.GetHash(Game1.currentLocation.objects);
this.PreviousTime = Game1.timeOfDay;
this.PreviousMineLevel = Game1.mine?.mineLevel ?? 0;
@@ -677,27 +691,27 @@ namespace StardewModdingAPI.Framework
{
if (Game1.debugMode)
{
- if (SGame._fpsStopwatch.IsRunning)
+ if (Game1._fpsStopwatch.IsRunning)
{
- float totalSeconds = (float)SGame._fpsStopwatch.Elapsed.TotalSeconds;
- SGame._fpsList.Add(totalSeconds);
- while (SGame._fpsList.Count >= 120)
- SGame._fpsList.RemoveAt(0);
+ float totalSeconds = (float)Game1._fpsStopwatch.Elapsed.TotalSeconds;
+ Game1._fpsList.Add(totalSeconds);
+ while (Game1._fpsList.Count >= 120)
+ Game1._fpsList.RemoveAt(0);
float num = 0.0f;
- foreach (float fps in SGame._fpsList)
+ foreach (float fps in Game1._fpsList)
num += fps;
- SGame._fps = (float)(1.0 / ((double)num / (double)SGame._fpsList.Count));
+ Game1._fps = (float)(1.0 / ((double)num / (double)Game1._fpsList.Count));
}
- SGame._fpsStopwatch.Restart();
+ Game1._fpsStopwatch.Restart();
}
else
{
- if (SGame._fpsStopwatch.IsRunning)
- SGame._fpsStopwatch.Reset();
- SGame._fps = 0.0f;
- SGame._fpsList.Clear();
+ if (Game1._fpsStopwatch.IsRunning)
+ Game1._fpsStopwatch.Reset();
+ Game1._fps = 0.0f;
+ Game1._fpsList.Clear();
}
- if (SGame._newDayTask != null)
+ if (Game1._newDayTask != null)
{
this.GraphicsDevice.Clear(this.bgColor);
//base.Draw(gameTime);
@@ -705,7 +719,7 @@ namespace StardewModdingAPI.Framework
else
{
if ((double)Game1.options.zoomLevel != 1.0)
- this.GraphicsDevice.SetRenderTarget(this.screenWrapper);
+ this.GraphicsDevice.SetRenderTarget(this.screen);
if (this.IsSaving)
{
this.GraphicsDevice.Clear(this.bgColor);
@@ -727,6 +741,12 @@ namespace StardewModdingAPI.Framework
this.RaisePostRender();
Game1.spriteBatch.End();
}
+ if (Game1.overlayMenu != null)
+ {
+ Game1.spriteBatch.Begin(SpriteSortMode.Deferred, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
+ Game1.overlayMenu.draw(Game1.spriteBatch);
+ Game1.spriteBatch.End();
+ }
//base.Draw(gameTime);
this.renderScreenBuffer();
}
@@ -755,7 +775,7 @@ namespace StardewModdingAPI.Framework
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
this.drawOverlays(Game1.spriteBatch);
@@ -784,7 +804,7 @@ namespace StardewModdingAPI.Framework
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
this.drawOverlays(Game1.spriteBatch);
@@ -813,7 +833,7 @@ namespace StardewModdingAPI.Framework
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
this.drawOverlays(Game1.spriteBatch);
@@ -845,7 +865,7 @@ namespace StardewModdingAPI.Framework
int widthOfString = SpriteText.getWidthOfString(str3);
int height = 64;
int x = 64;
- int y = Game1.graphics.GraphicsDevice.Viewport.TitleSafeArea.Bottom - height;
+ int y = Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - height;
SpriteText.drawString(Game1.spriteBatch, s, x, y, 999999, widthOfString, height, 1f, 0.88f, false, 0, str3, -1);
Game1.spriteBatch.End();
if ((double)Game1.options.zoomLevel != 1.0)
@@ -853,10 +873,11 @@ namespace StardewModdingAPI.Framework
this.GraphicsDevice.SetRenderTarget((RenderTarget2D)null);
this.GraphicsDevice.Clear(this.bgColor);
Game1.spriteBatch.Begin(SpriteSortMode.Immediate, BlendState.AlphaBlend, SamplerState.LinearClamp, DepthStencilState.Default, RasterizerState.CullNone);
- Game1.spriteBatch.Draw((Texture2D)this.screenWrapper, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screenWrapper.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
+ Game1.spriteBatch.Draw((Texture2D)this.screen, Vector2.Zero, new Microsoft.Xna.Framework.Rectangle?(this.screen.Bounds), Color.White, 0.0f, Vector2.Zero, Game1.options.zoomLevel, SpriteEffects.None, 1f);
Game1.spriteBatch.End();
}
this.drawOverlays(Game1.spriteBatch);
+ //base.Draw(gameTime);
}
else
{
@@ -896,7 +917,7 @@ namespace StardewModdingAPI.Framework
}
}
Game1.spriteBatch.End();
- this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screenWrapper);
+ this.GraphicsDevice.SetRenderTarget((double)Game1.options.zoomLevel == 1.0 ? (RenderTarget2D)null : this.screen);
}
if (Game1.bloomDay && Game1.bloom != null)
Game1.bloom.BeginDraw();
@@ -956,6 +977,27 @@ namespace StardewModdingAPI.Framework
}
}
}
+ foreach (SFarmer farmer in Game1.currentLocation.getFarmers())
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation == null || !Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
+ {
+ SpriteBatch spriteBatch = Game1.spriteBatch;
+ Texture2D shadowTexture = Game1.shadowTexture;
+ Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
+ Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
+ Color white = Color.White;
+ double num2 = 0.0;
+ Microsoft.Xna.Framework.Rectangle bounds2 = Game1.shadowTexture.Bounds;
+ double x = (double)bounds2.Center.X;
+ bounds2 = Game1.shadowTexture.Bounds;
+ double y = (double)bounds2.Center.Y;
+ Vector2 origin = new Vector2((float)x, (float)y);
+ double num3 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
+ int num4 = 0;
+ double num5 = 0.0;
+ spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
+ }
+ }
Game1.currentLocation.Map.GetLayer("Buildings").Draw(Game1.mapDisplayDevice, Game1.viewport, Location.Origin, false, 4);
Game1.mapDisplayDevice.EndScene();
Game1.spriteBatch.End();
@@ -976,6 +1018,27 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.Draw(Game1.shadowTexture, Game1.GlobalToLocal(Game1.viewport, actor.Position + new Vector2((float)(actor.Sprite.SpriteWidth * 4) / 2f, (float)(actor.GetBoundingBox().Height + (actor.IsMonster ? 0 : 12)))), new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds), Color.White, 0.0f, new Vector2((float)Game1.shadowTexture.Bounds.Center.X, (float)Game1.shadowTexture.Bounds.Center.Y), (float)(4.0 + (double)actor.yJumpOffset / 40.0) * (float)((NetFieldBase<float, NetFloat>)actor.scale), SpriteEffects.None, Math.Max(0.0f, (float)actor.getStandingY() / 10000f) - 1E-06f);
}
}
+ foreach (SFarmer farmer in Game1.currentLocation.getFarmers())
+ {
+ if (!(bool)((NetFieldBase<bool, NetBool>)farmer.swimming) && !farmer.isRidingHorse() && (Game1.currentLocation != null && Game1.currentLocation.shouldShadowBeDrawnAboveBuildingsLayer(farmer.getTileLocation())))
+ {
+ SpriteBatch spriteBatch = Game1.spriteBatch;
+ Texture2D shadowTexture = Game1.shadowTexture;
+ Vector2 local = Game1.GlobalToLocal(farmer.Position + new Vector2(32f, 24f));
+ Microsoft.Xna.Framework.Rectangle? sourceRectangle = new Microsoft.Xna.Framework.Rectangle?(Game1.shadowTexture.Bounds);
+ Color white = Color.White;
+ double num2 = 0.0;
+ Microsoft.Xna.Framework.Rectangle bounds2 = Game1.shadowTexture.Bounds;
+ double x = (double)bounds2.Center.X;
+ bounds2 = Game1.shadowTexture.Bounds;
+ double y = (double)bounds2.Center.Y;
+ Vector2 origin = new Vector2((float)x, (float)y);
+ double num3 = 4.0 - (!farmer.running && !farmer.UsingTool || farmer.FarmerSprite.currentAnimationIndex <= 1 ? 0.0 : (double)Math.Abs(FarmerRenderer.featureYOffsetPerFrame[farmer.FarmerSprite.CurrentFrame]) * 0.5);
+ int num4 = 0;
+ double num5 = 0.0;
+ spriteBatch.Draw(shadowTexture, local, sourceRectangle, white, (float)num2, origin, (float)num3, (SpriteEffects)num4, (float)num5);
+ }
+ }
if ((Game1.eventUp || Game1.killScreen) && (!Game1.killScreen && Game1.currentLocation.currentEvent != null))
Game1.currentLocation.currentEvent.draw(Game1.spriteBatch);
if (Game1.player.currentUpgrade != null && Game1.player.currentUpgrade.daysLeftTillUpgradeDone <= 3 && Game1.currentLocation.Name.Equals("Farm"))
@@ -1054,7 +1117,6 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.Draw(Game1.rainTexture, Game1.rainDrops[index].position, new Microsoft.Xna.Framework.Rectangle?(Game1.getSourceRectForStandardTileSheet(Game1.rainTexture, Game1.rainDrops[index].frame, -1, -1)), Color.White);
}
Game1.spriteBatch.End();
- //base.Draw(gameTime);
Game1.spriteBatch.Begin(SpriteSortMode.FrontToBack, BlendState.AlphaBlend, SamplerState.PointClamp, (DepthStencilState)null, (RasterizerState)null);
if (Game1.eventUp && Game1.currentLocation.currentEvent != null)
{
@@ -1155,30 +1217,8 @@ namespace StardewModdingAPI.Framework
this.drawDialogueBox();
if (Game1.progressBar)
{
- SpriteBatch spriteBatch1 = Game1.spriteBatch;
- Texture2D fadeToBlackRect = Game1.fadeToBlackRect;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- int x1 = (viewport1.TitleSafeArea.Width - Game1.dialogueWidth) / 2;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- Microsoft.Xna.Framework.Rectangle titleSafeArea = viewport1.TitleSafeArea;
- int y1 = titleSafeArea.Bottom - 128;
- int dialogueWidth = Game1.dialogueWidth;
- int height1 = 32;
- Microsoft.Xna.Framework.Rectangle destinationRectangle1 = new Microsoft.Xna.Framework.Rectangle(x1, y1, dialogueWidth, height1);
- Color lightGray = Color.LightGray;
- spriteBatch1.Draw(fadeToBlackRect, destinationRectangle1, lightGray);
- SpriteBatch spriteBatch2 = Game1.spriteBatch;
- Texture2D staminaRect = Game1.staminaRect;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- int x2 = (viewport1.TitleSafeArea.Width - Game1.dialogueWidth) / 2;
- viewport1 = Game1.graphics.GraphicsDevice.Viewport;
- titleSafeArea = viewport1.TitleSafeArea;
- int y2 = titleSafeArea.Bottom - 128;
- int width = (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth);
- int height2 = 32;
- Microsoft.Xna.Framework.Rectangle destinationRectangle2 = new Microsoft.Xna.Framework.Rectangle(x2, y2, width, height2);
- Color dimGray = Color.DimGray;
- spriteBatch2.Draw(staminaRect, destinationRectangle2, dimGray);
+ Game1.spriteBatch.Draw(Game1.fadeToBlackRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, Game1.dialogueWidth, 32), Color.LightGray);
+ Game1.spriteBatch.Draw(Game1.staminaRect, new Microsoft.Xna.Framework.Rectangle((Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Width - Game1.dialogueWidth) / 2, Game1.graphics.GraphicsDevice.Viewport.GetTitleSafeArea().Bottom - 128, (int)((double)Game1.pauseAccumulator / (double)Game1.pauseTime * (double)Game1.dialogueWidth), 32), Color.DimGray);
}
if (Game1.eventUp && (Game1.currentLocation != null && Game1.currentLocation.currentEvent != null))
Game1.currentLocation.currentEvent.drawAfterMap(Game1.spriteBatch);
@@ -1219,38 +1259,31 @@ namespace StardewModdingAPI.Framework
overlayTempSprite.draw(Game1.spriteBatch, true, 0, 0, 1f);
if (Game1.debugMode)
{
- SpriteBatch spriteBatch = Game1.spriteBatch;
- SpriteFont smallFont = Game1.smallFont;
- object[] objArray = new object[10];
- int index = 0;
- string str;
- if (!Game1.panMode)
- str = "player: " + (object)(Game1.player.getStandingX() / 64) + ", " + (object)(Game1.player.getStandingY() / 64);
+ StringBuilder debugStringBuilder = SGame._debugStringBuilder;
+ debugStringBuilder.Clear();
+ if (Game1.panMode)
+ {
+ debugStringBuilder.Append((Game1.getOldMouseX() + Game1.viewport.X) / 64);
+ debugStringBuilder.Append(",");
+ debugStringBuilder.Append((Game1.getOldMouseY() + Game1.viewport.Y) / 64);
+ }
else
- str = ((Game1.getOldMouseX() + Game1.viewport.X) / 64).ToString() + "," + (object)((Game1.getOldMouseY() + Game1.viewport.Y) / 64);
- objArray[index] = (object)str;
- objArray[1] = (object)" mouseTransparency: ";
- objArray[2] = (object)Game1.mouseCursorTransparency;
- objArray[3] = (object)" mousePosition: ";
- objArray[4] = (object)Game1.getMouseX();
- objArray[5] = (object)",";
- objArray[6] = (object)Game1.getMouseY();
- objArray[7] = (object)Environment.NewLine;
- objArray[8] = (object)"debugOutput: ";
- objArray[9] = (object)Game1.debugOutput;
- string text = string.Concat(objArray);
- Viewport viewport2 = this.GraphicsDevice.Viewport;
- double x = (double)viewport2.TitleSafeArea.X;
- viewport2 = this.GraphicsDevice.Viewport;
- double y = (double)viewport2.TitleSafeArea.Y;
- Vector2 position = new Vector2((float)x, (float)y);
- Color red = Color.Red;
- double num2 = 0.0;
- Vector2 zero = Vector2.Zero;
- double num3 = 1.0;
- int num4 = 0;
- double num5 = 0.99999988079071;
- spriteBatch.DrawString(smallFont, text, position, red, (float)num2, zero, (float)num3, (SpriteEffects)num4, (float)num5);
+ {
+ debugStringBuilder.Append("player: ");
+ debugStringBuilder.Append(Game1.player.getStandingX() / 64);
+ debugStringBuilder.Append(", ");
+ debugStringBuilder.Append(Game1.player.getStandingY() / 64);
+ }
+ debugStringBuilder.Append(" mouseTransparency: ");
+ debugStringBuilder.Append(Game1.mouseCursorTransparency);
+ debugStringBuilder.Append(" mousePosition: ");
+ debugStringBuilder.Append(Game1.getMouseX());
+ debugStringBuilder.Append(",");
+ debugStringBuilder.Append(Game1.getMouseY());
+ debugStringBuilder.Append(Environment.NewLine);
+ debugStringBuilder.Append("debugOutput: ");
+ debugStringBuilder.Append(Game1.debugOutput);
+ Game1.spriteBatch.DrawString(Game1.smallFont, debugStringBuilder, new Vector2((float)this.GraphicsDevice.Viewport.GetTitleSafeArea().X, (float)(this.GraphicsDevice.Viewport.GetTitleSafeArea().Y + Game1.smallFont.LineSpacing * 8)), Color.Red, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
}
if (Game1.showKeyHelp)
Game1.spriteBatch.DrawString(Game1.smallFont, Game1.keyHelpString, new Vector2(64f, (float)(Game1.viewport.Height - 64 - (Game1.dialogueUp ? 192 + (Game1.isQuestion ? Game1.questionChoices.Count * 64 : 0) : 0)) - Game1.smallFont.MeasureString(Game1.keyHelpString).Y), Color.LightGray, 0.0f, Vector2.Zero, 1f, SpriteEffects.None, 0.9999999f);
@@ -1279,6 +1312,7 @@ namespace StardewModdingAPI.Framework
Game1.spriteBatch.End();
this.drawOverlays(Game1.spriteBatch);
this.renderScreenBuffer();
+ //base.Draw(gameTime);
}
}
}
diff --git a/src/SMAPI/Program.cs b/src/SMAPI/Program.cs
index 1b8cb2ba..ff4e9a50 100644
--- a/src/SMAPI/Program.cs
+++ b/src/SMAPI/Program.cs
@@ -629,7 +629,7 @@ namespace StardewModdingAPI
// compare versions
bool isUpdate = remoteVersion.IsNewerThan(localVersion);
- this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {remoteInfo.Version}{(!remoteVersion.Equals(new SemanticVersion(remoteInfo.Version)) ? $" [{remoteVersion}]" : "")}" : "okay")}.");
+ this.VerboseLog($" {mod.DisplayName} ({result.Key}): {(isUpdate ? $"{mod.Manifest.Version}{(!localVersion.Equals(mod.Manifest.Version) ? $" [{localVersion}]" : "")} => {remoteInfo.Version}" : "okay")}.");
if (isUpdate)
{
if (!updatesByMod.TryGetValue(mod, out ModInfoModel other) || remoteVersion.IsNewerThan(other.Version))
@@ -736,7 +736,7 @@ namespace StardewModdingAPI
// get content packs by mod ID
IDictionary<string, IContentPack[]> contentPacksByModID =
loadedContentPacks
- .GroupBy(p => p.Manifest.ContentPackFor.UniqueID)
+ .GroupBy(p => p.Manifest.ContentPackFor.UniqueID, StringComparer.InvariantCultureIgnoreCase)
.ToDictionary(
group => group.Key,
group => group.Select(metadata => metadata.ContentPack).ToArray(),
diff --git a/src/SMAPI/StardewModdingAPI.config.json b/src/SMAPI/StardewModdingAPI.config.json
index 5b6bae12..f7082e96 100644
--- a/src/SMAPI/StardewModdingAPI.config.json
+++ b/src/SMAPI/StardewModdingAPI.config.json
@@ -59,7 +59,7 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
* - A JSON structure containing any of four manifest fields (ID, Name, Author, and
* EntryDll) to match.
*
- * - MapLocalVersions and MapRemoteVersions crrect local manifest versions and remote versions
+ * - MapLocalVersions and MapRemoteVersions correct local manifest versions and remote versions
* during update checks. For example, if the API returns version '1.1-1078' where '1078' is
* intended to be a build number, MapRemoteVersions can map it to '1.1' when comparing to the
* mod's current version. This is only meant to support legacy mods with injected update keys.