diff options
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: + + +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 Binary files differnew file mode 100644 index 00000000..3b930dc5 --- /dev/null +++ b/docs/screenshots/code-analyzer-example.png 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. |