Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2018-08-01 11:07:29 -04:00
commit 60b4119577
292 changed files with 12425 additions and 7772 deletions

View File

@ -12,12 +12,11 @@ insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
[*.csproj]
[*.{csproj,json,nuspec,targets}]
indent_size = 2
insert_final_newline = false
[*.json]
indent_size = 2
[*.csproj]
insert_final_newline = false
##########
## C# formatting

View File

@ -1,16 +1,18 @@
Do you want to...
* **Ask for help using SMAPI?**
Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375),
don't create a GitHub issue.
Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375)
or [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord), don't create a
GitHub issue.
* **Report a bug?**
Please post a message in the [SMAPI support thread](http://community.playstarbound.com/threads/108375)
instead, unless you're sure it's a bug in SMAPI itself.
or [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord) instead, unless
you're sure it's a bug in SMAPI itself.
* **Submit a pull request?**
Pull requests are welcome! If you're submitting a new feature, it's best to discuss first to make
sure it'll be accepted. Feel free to come chat in [#modding on Discord](https://discord.gg/kH55QXP)
sure it'll be accepted. Feel free to come chat in [#modding on Discord](https://stardewvalleywiki.com/Modding:Community#Discord)
or post in the [SMAPI support thread](http://community.playstarbound.com/threads/108375).
Documenting your code and using the same formatting conventions is appreciated, but don't worry too

32
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,32 @@
---
name: Bug report
about: Report a problem with SMAPI.
---
<!--
Only report a bug here if you're sure it's a SMAPI bug! To request support instead, see:
- #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
- support forum thread: https://community.playstarbound.com/threads/108375
- Nexus mod page: https://www.nexusmods.com/stardewvalley/mods/2400
Replace the instructions below with the bug details.
-->
**Describe the bug**
A clear and concise description of what the bug is. Provide any other details you think might be relevant here.
**To Reproduce**
Exact steps which reproduce the bug, if possible. For example:
1. Load save '...'.
2. Walk to '....'.
3. Click '....'.
4. Error occurs.
**Log file**
Upload your SMAPI log to https://log.smapi.io and post a link here.
**Screenshots**
If applicable, add screenshots to help explain your problem.

View File

@ -0,0 +1,15 @@
---
name: Feature request
about: Suggest an idea for SMAPI.
---
<!--
GitHub issues are only used for development tasks. Please don't submit feature requests here! Instead, see...
- #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
- support forum thread: https://community.playstarbound.com/threads/108375
- Nexus page: https://www.nexusmods.com/stardewvalley/mods/2400
-->

15
.github/ISSUE_TEMPLATE/general.md vendored Normal file
View File

@ -0,0 +1,15 @@
---
name: General
about: Create a ticket about something else.
---
<!--
GitHub issues are only used for development tasks. For support and questions, see...
- #modding on Discord: https://stardewvalleywiki.com/Modding:Community#Discord
- support forum thread: https://community.playstarbound.com/threads/108375
- Nexus page: https://www.nexusmods.com/stardewvalley/mods/2400
-->

5
.github/SUPPORT.md vendored Normal file
View File

@ -0,0 +1,5 @@
GitHub issues are only used for SMAPI development tasks.
To get help with SMAPI problems, you can...
* [ask on Discord](https://stardewvalleywiki.com/Modding:Community#Discord);
* or post in the [SMAPI support thread](https://community.playstarbound.com/threads/108375).

View File

@ -1,5 +1,5 @@
using System.Reflection;
[assembly: AssemblyProduct("SMAPI")]
[assembly: AssemblyVersion("2.5.5")]
[assembly: AssemblyFileVersion("2.5.5")]
[assembly: AssemblyVersion("2.6.0")]
[assembly: AssemblyFileVersion("2.6.0")]

View File

@ -14,65 +14,78 @@
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/Library/Application Support/Steam/steamapps/common/Stardew Valley/Contents/MacOS</GamePath>
<!-- Windows paths -->
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath>
<GamePath Condition="!Exists('$(GamePath)') AND '$(OS)' == 'Windows_NT'">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath>
<!--compile constants -->
<DefineConstants Condition="$(OS) == 'Windows_NT'">$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
</PropertyGroup>
<!-- add references-->
<!-- add common references -->
<ItemGroup>
<Reference Condition="'$(OS)' == 'Windows_NT' AND '$(MSBuildProjectName)' != 'StardewModdingAPI.Toolkit' AND '$(MSBuildProjectName)' != 'StardewModdingAPI.Toolkit.CoreInterfaces'" Include="System.Management" />
</ItemGroup>
<!-- add game references-->
<Choose>
<When Condition="$(OS) == 'Windows_NT'">
<PropertyGroup>
<DefineConstants>$(DefineConstants);SMAPI_FOR_WINDOWS</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Netcode" Condition="Exists('$(GamePath)\Netcode.dll')">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
</Reference>
<Reference Include="Stardew Valley">
<HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
<Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
</Reference>
<Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
</ItemGroup>
<When Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.SaveBackup' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Tests'">
<Choose>
<When Condition="$(OS) == 'Windows_NT'">
<ItemGroup>
<!--XNA framework-->
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>False</Private>
</Reference>
<!-- game DLLs -->
<Reference Include="Netcode">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
</Reference>
<Reference Include="Stardew Valley">
<HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
<Private Condition="'$(MSBuildProjectName)' != 'StardewModdingAPI.Tests'">False</Private>
</Reference>
<Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
</ItemGroup>
</When>
<Otherwise>
<ItemGroup>
<!-- MonoGame -->
<Reference Include="MonoGame.Framework">
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
<!-- game DLLs -->
<Reference Include="StardewValley">
<HintPath>$(GamePath)\StardewValley.exe</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="xTile">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
</Otherwise>
</Choose>
</When>
<Otherwise>
<PropertyGroup>
<DefineConstants>$(DefineConstants);SMAPI_FOR_UNIX</DefineConstants>
</PropertyGroup>
<ItemGroup>
<Reference Include="MonoGame.Framework">
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="StardewValley">
<HintPath>$(GamePath)\StardewValley.exe</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="xTile">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>False</Private>
</Reference>
</ItemGroup>
</Otherwise>
</Choose>
<!-- if game path is invalid, show one user-friendly error instead of a slew of reference errors -->
@ -82,21 +95,31 @@
<!-- copy files into game directory and enable debugging (only in debug mode) -->
<Target Name="AfterBuild">
<CallTarget Targets="CopySMAPI;CopyDefaultMod" Condition="'$(Configuration)' == 'Debug'" />
<CallTarget Targets="CopySMAPI;CopyDefaultMods" Condition="'$(Configuration)' == 'Debug'" />
</Target>
<Target Name="CopySMAPI" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI'">
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).config.json" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).metadata.json" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).xml" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\0Harmony.dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\0Harmony.pdb" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)" />
</Target>
<Target Name="CopyDefaultMod" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands'">
<Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" Condition="Exists('$(TargetDir)\$(TargetName).pdb')" />
<Copy SourceFiles="$(TargetDir)\manifest.json" DestinationFolder="$(GamePath)\Mods\ConsoleCommands" />
<Target Name="CopyDefaultMods" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'StardewModdingAPI.Mods.SaveBackup'">
<Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" Condition="Exists('$(TargetDir)\$(TargetName).pdb')" />
<Copy SourceFiles="$(TargetDir)\manifest.json" DestinationFolder="$(GamePath)\Mods\$(AssemblyName)" />
</Target>
<Target Name="CopyToolkit" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Toolkit' AND '$(Configuration)' == 'Debug' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
</Target>
<Target Name="CopyToolkitCoreInterfaces" Condition="'$(MSBuildProjectName)' == 'StardewModdingAPI.Toolkit.CoreInterfaces' AND '$(Configuration)' == 'Debug' AND $(TargetFramework) == 'net4.5'" AfterTargets="PostBuildEvent">
<Copy SourceFiles="$(TargetDir)\$(TargetName).dll" DestinationFolder="$(GamePath)" />
<Copy SourceFiles="$(TargetDir)\$(TargetName).pdb" DestinationFolder="$(GamePath)" />
</Target>
<!-- launch SMAPI on debug -->

View File

@ -7,7 +7,9 @@
-->
<Target Name="AfterBuild">
<PropertyGroup>
<CompiledSmapiPath>$(SolutionDir)\..\bin\$(Configuration)\SMAPI</CompiledSmapiPath>
<CompiledRootPath>$(SolutionDir)\..\bin\$(Configuration)</CompiledRootPath>
<CompiledSmapiPath>$(CompiledRootPath)\SMAPI</CompiledSmapiPath>
<CompiledToolkitPath>$(CompiledRootPath)\SMAPI.Toolkit\net4.5</CompiledToolkitPath>
<PackagePath>$(SolutionDir)\..\bin\Packaged</PackagePath>
<PackageInternalPath>$(PackagePath)\internal</PackageInternalPath>
</PropertyGroup>
@ -18,33 +20,50 @@
<RemoveDir Directories="$(PackagePath)" />
<!-- copy installer files -->
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\install.exe" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\install on Windows.exe" />
<Copy SourceFiles="$(TargetDir)\readme.txt" DestinationFiles="$(PackagePath)\README.txt" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-launcher.sh" DestinationFiles="$(PackageInternalPath)\Mono\StardewModdingAPI" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install.sh" />
<!-- copy SMAPI files for Mono -->
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackageInternalPath)\Mono\install.exe" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.metadata.json" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Runtime.Caching.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackageInternalPath)\Mono" />
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Mono\Mods\%(RecursiveDir)" />
<!-- copy SMAPI files for Windows -->
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\0Harmony.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.AssemblyRewriters.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.config.json" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.metadata.json" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(CompiledToolkitPath)\StardewModdingAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackageInternalPath)\Windows" />
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="@(CompiledMods)" DestinationFolder="$(PackageInternalPath)\Windows\Mods\%(RecursiveDir)" />
</Target>
</Project>

View File

@ -13,6 +13,8 @@
<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="$(TargetDir)/StardewModdingAPI.Toolkit.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.Toolkit.dll" />
<Copy SourceFiles="$(TargetDir)/StardewModdingAPI.Toolkit.CoreInterfaces.dll" DestinationFiles="$(PackagePath)/build/StardewModdingAPI.Toolkit.CoreInterfaces.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" />

View File

@ -1,6 +1,6 @@
**SMAPI** is an open-source modding API for [Stardew Valley](http://stardewvalley.net/) that lets
you play the game with mods. It's safely installed alongside the game's executable, and doesn't
change any of your game files. It serves six main purposes:
change any of your game files. It serves eight main purposes:
1. **Load mods into the game.**
_SMAPI loads mods when the game is starting up so they can interact with it. (Code mods aren't
@ -25,16 +25,24 @@ change any of your game files. It serves six main purposes:
crashing the game, and makes it possible to troubleshoot errors in the game itself that would
otherwise show a generic 'program has stopped working' type of message._
6. **Provide update checks.**
6. **Provide update checks.**
_SMAPI automatically checks for new versions of your installed mods, and notifies you when any
are available._
7. **Provide compatibility checks.**
_SMAPI automatically detects outdated or broken code in mods, and safely disables them before
they cause problems._
8. **Back up your save files.**
_SMAPI automatically creates a daily backup of your saves and keeps ten backups, in case
something goes wrong. (Via the bundled SaveBackup mod.)_
## Documentation
Have questions? Come [chat on Discord](https://discord.gg/KCJHWhX) with SMAPI developers and other
modders!
### For players
* [Modding guides](https://stardewvalleywiki.com/Modding:Index#For_players)
* [Player guide](https://stardewvalleywiki.com/Modding:Player_Guide)
### For modders
* [Modding documentation](https://stardewvalleywiki.com/Modding:Index)

View File

@ -121,18 +121,30 @@ or you have multiple installs, you can specify the path yourself. There's two wa
The configuration will check your custom path first, then fall back to the default paths (so it'll
still compile on a different computer).
### Unit test projects
### Ignore files
If you don't want to include a file in the mod folder or release zip:
* Make sure it's not copied to the build output. For a DLL, make sure the reference is [not marked 'copy local'](https://msdn.microsoft.com/en-us/library/t1zz5y8c(v=vs.100).aspx).
* Or add this to your `.csproj` file under the `<Project` line:
```xml
<IgnoreModFilePatterns>\.txt$, \.pdf$</IgnoreModFilePatterns>
```
This is a comma-delimited list of regular expression patterns. If any pattern matches a file's
relative path in your mod folder, that file won't be included.
### Non-mod projects
**(upcoming in 2.1)**
You can use the package in unit test projects too. Its optional unit test mode...
You can use the package in non-mod projects too (e.g. unit tests or framework DLLs). You'll need to
disable deploying the mod and creating a release zip:
1. disables deploying the project as a mod;
2. disables creating a release zip;
2. and copies the referenced DLLs into the build output for unit test frameworks.
To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
```xml
<ModUnitTests>True</ModUnitTests>
<EnableModDeploy>False</EnableModDeploy>
<EnableModZip>False</EnableModZip>
```
If this is for unit tests, you may need to copy the referenced DLLs into your build output too:
```xml
<CopyModReferencesToBuildOutput>True</CopyModReferencesToBuildOutput>
```
## Code warnings
@ -140,11 +152,11 @@ To enable it, add this above the first `</PropertyGroup>` in your `.csproj`:
The NuGet package adds code warnings in Visual Studio specific to Stardew Valley. For example:
![](screenshots/code-analyzer-example.png)
You can hide the warnings...
You can hide the warnings using the warning ID (shown under 'code' in the Error List). See...
* [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
[System.Diagnostics.CodeAnalysis.SuppressMessage("SMAPI.CommonErrors", "AvoidNetField")]
```
* for an entire project:
1. Expand the _References_ node for the project in Visual Studio.
@ -153,8 +165,8 @@ You can hide the warnings...
See below for help with each specific warning.
### SMAPI001
**Implicit net field conversion:**
### Avoid implicit net field cast
Warning text:
> 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.
@ -163,11 +175,11 @@ Stardew Valley uses net types (like `NetBool` and `NetInt`) to handle multiplaye
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.
`building.indoors != null` can be true for a null value.
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
`monster.health` (`NetInt`). The package will add a separate [AvoidNetField](#avoid-net-field) 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#
@ -185,17 +197,17 @@ Suggested fix:
if (item != null && item.category.Value == 0)
```
### SMAPI002
**Avoid net fields when possible:**
### Avoid net field
Warning text:
> '{{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.
Your code accesses a net field, which has some unusual behavior (see [AvoidImplicitNetFieldCast](#avoid-implicit-net-field-cast)).
This field has an equivalent non-net property that avoids those issues.
Suggested fix: access the suggested property name instead.
### SMAPI003
**Avoid obsolete fields:**
### Avoid obsolete field
Warning text:
> 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.

View File

@ -1,21 +1,103 @@
# Release notes
<!--
## 2.6 alpha
## 2.6
* For players:
* Added support for Stardew Valley 1.3+; no longer compatible with earlier versions.
* Added `Context.IsMultiplayer` and `Context.IsMainPlayer` flags.
* Fixed SMAPI update checks not showing newer beta versions when using a beta version.
* Updated for Stardew Valley 1.3.
* Added automatic save backups.
* Improved update checks:
* added beta update channel;
* added update alerts for incompatible mods with an unofficial update on the wiki;
* added update alerts for optional files on Nexus;
* added console warning for mods which don't have update checks configured;
* added more visible prompt in beta channel for SMAPI updates;
* fixed mod update checks failing if a mod only has prerelease versions on GitHub;
* fixed Nexus mod update alerts not showing HTTPS links.
* Improved mod warnings in the console.
* Improved error when game can't start audio.
* Improved the Console Commands mod:
* Added `player_add name`, which adds items to your inventory by name instead of ID.
* Fixed `world_setseason` not running season-change logic.
* Fixed `world_setseason` not normalising the season value.
* Fixed `world_settime` sometimes breaking NPC schedules (e.g. so they stay in bed).
* Removed the `player_setlevel` and `player_setspeed` commands, which weren't implemented in a useful way. Use a mod like CJB Cheats Menu if you need those.
* Fixed `SEHException` errors for some players.
* Fixed performance issues for some players.
* Fixed default color scheme on Mac or in PowerShell (configurable via `StardewModdingAPI.config.json`).
* Fixed installer error on Linux/Mac in some cases.
* Fixed installer not finding some game paths or showing duplicate paths.
* Fixed installer not removing some SMAPI files.
* Fixed launch issue for Linux players with some terminals. (Thanks to HanFox and kurumushi!)
* Fixed abort-retry loop if a mod crashed when intercepting assets during startup.
* Fixed some mods failing if the player name is blank.
* Fixed errors when a mod references a missing assembly.
* Fixed `AssemblyResolutionException` errors in rare cases.
* Renamed `install.exe` to `install on Windows.exe` to avoid confusion.
* Updated compatibility list.
* For the web UI:
* Added option to download SMAPI from Nexus.
* Added log parser redesign that should be more intuitive.
* Added log parser option to view raw log.
* Changed log parser filters to show `DEBUG` messages by default.
* Fixed design on smaller screens.
* Fixed log parser issue when content packs have no description.
* Fixed log parser mangling crossplatform paths in some cases.
* Fixed `smapi.io/install` not linking to a useful page.
* For modders:
* Added code analysis to mod build config package to flag common issues as warnings.
* Dropped some deprecated APIs.
* Fixed assets loaded by temporary content managers not being editable.
* Fixed issue where assets didn't reload correctly when the player switches language.
* Added [input API](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Input) for reading and suppressing keyboard, controller, and mouse input.
* Added code analysis in the NuGet package to flag common issues as warnings.
* Replaced `LocationEvents` to support multiplayer:
* now raised for all locations;
* now includes added/removed building interiors;
* each event now provides a list of added/removed values;
* added buildings-changed event.
* Added `Context.IsMultiplayer` and `Context.IsMainPlayer` flags.
* Added `Constants.TargetPlatform` which says whether the game is running on Linux, Mac, or Windows.
* Added `semanticVersion.IsPrerelease()` method.
* Added support for launching multiple instances transparently. This removes the former `--log-path` command-line argument.
* Added support for custom seasonal tilesheets when loading an unpacked `.tbin` map.
* Added Harmony DLL for internal use by SMAPI. (Mods should still include their own copy for backwards compatibility, and in case it's removed later. SMAPI will always load its own version though.)
* Added option to suppress update checks for a specific mod in `StardewModdingAPI.config.json`.
* Added absolute pixels to `ICursorPosition`.
* Added support for reading/writing `ISemanticVersion` to JSON.
* Added support for reloading NPC schedules through the content API.
* Reimplemented the content API so it works more reliably in many edge cases.
* Reimplemented input suppression to work more consistently in many cases.
* The order of update keys now affects which URL players see in update alerts.
* Fixed assets loaded by temporary content managers not being editable by mods.
* Fixed assets not reloaded consistently when the player switches language.
* Fixed error if a mod loads a PNG while the game is loading (e.g. custom map tilesheets via `IAssetLoader`).
* Fixed error if a mod translation file is empty.
* Fixed input suppression not working consistently for clicks.
* Fixed console input not saved to the log.
* Fixed `Context.IsPlayerFree` being false during festivals.
* Fixed `helper.ModRegistry.GetApi` errors not always mentioning which interface caused the issue.
* Fixed console commands being invoked asynchronously.
* Fixed mods able to intercept other mods' assets via the internal asset keys.
* Fixed mods able to indirectly change other mods' data through shared content caches.
* Fixed `SemanticVersion` allowing invalid versions in some cases.
* **Breaking changes** (see [migration guide](https://stardewvalleywiki.com/Modding:Migrate_to_Stardew_Valley_1.3)):
* Dropped some deprecated APIs.
* `LocationEvents` have been rewritten.
* Mods can't intercept chatbox input.
* Mod IDs should only contain letters, numbers, hyphens, dots, and underscores. That allows their use in many contexts like URLs. This restriction is now enforced. (In regex form: `^[a-zA-Z0-9_.-]+$`.)
* 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.
-->
* Added more consistent crossplatform handling, including MacOS detection.
* Added beta update channel.
* Added optional mod metadata to the web API (including Nexus info, wiki metadata, etc).
* Added early prototype of SMAPI 3.0 events via `helper.Events`.
* Added early prototype of mod handler toolkit.
* Added Harmony for SMAPI's internal use.
* Added metadata dump option in `StardewModdingAPI.config.json` for troubleshooting some cases.
* Added more stylish pufferchick on the home page.
* Rewrote update checks:
* Moved most logic into the web API.
* Changed web API to require mod IDs.
* Changed web API to also fetch metadata from SMAPI's internal mod DB and the wiki.
* Rewrote world/player state tracking. The new implementation is much more efficient than previous method, uses net field events where available, and lays the groundwork for more advanced events in SMAPI 3.0.
* Split mod DB out of `StardewModdingAPI.config.json` into its own file.
* Updated to Mono.Cecil 0.10.
## 2.5.5
* For players:
@ -215,7 +297,7 @@
* **New features for modders**
SMAPI 2.0 adds several features to enable new kinds of mods (see
[API documentation](https://stardewvalleywiki.com/Modding:SMAPI_APIs)).
[API documentation](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs)).
The **content API** lets you edit, inject, and reload XNB data loaded by the game at any time. This lets SMAPI mods do
anything previously only possible with XNB mods, and enables new mod scenarios not possible with XNB mods (e.g.
@ -338,8 +420,8 @@ For players:
* Updated mod compatibility list.
For modders:
* Added `SDate` utility for in-game date calculations (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Dates)).
* Added support for minimum dependency versions in `manifest.json` (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Manifest)).
* Added `SDate` utility for in-game date calculations (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Utilities#Dates)).
* Added support for minimum dependency versions in `manifest.json` (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest)).
* Added more useful logging when loading mods.
* Added a `ModID` property to all mod helpers for extension methods.
* Changed `manifest.MinimumApiVersion` from string to `ISemanticVersion`. This shouldn't affect mods unless they referenced that field in code.
@ -371,8 +453,8 @@ For players:
* Updated mod compatibility list.
For modders:
* You can now add dependencies to `manifest.json` (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Manifest)).
* You can now translate your mod (see [API reference](http://stardewvalleywiki.com/Modding:SMAPI_APIs#Translation)).
* You can now add dependencies to `manifest.json` (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest)).
* You can now translate your mod (see [API reference](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Translation)).
* You can now load unpacked `.tbin` files from your mod folder through the content API.
* SMAPI now automatically fixes tilesheet references for maps loaded from the mod folder.
<small>_When loading a map from the mod folder, SMAPI will automatically use tilesheets relative to the map file if they exists. Otherwise it will default to tilesheets in the game content._</small>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -44,7 +44,7 @@ executed. This doesn't work in MonoDevelop on Linux, unfortunately.
### Preparing a release
To prepare a crossplatform SMAPI release, you'll need to compile it on two platforms. See
[crossplatforming info](http://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod#Test_on_all_platforms)
[crossplatforming info](https://stardewvalleywiki.com/Modding:Creating_a_SMAPI_mod#Test_on_all_platforms)
on the wiki for the first-time setup.
1. Update the version number in `GlobalAssemblyInfo.cs` and `Constants::Version`. Make sure you use a
@ -78,8 +78,9 @@ on the wiki for the first-time setup.
Mono.Cecil.dll
Newtonsoft.Json.dll
StardewModdingAPI
StardewModdingAPI.AssemblyRewriters.dll
StardewModdingAPI.config.json
StardewModdingAPI.Internal.dll
StardewModdingAPI.metadata.json
StardewModdingAPI.exe
StardewModdingAPI.pdb
StardewModdingAPI.xml
@ -91,8 +92,9 @@ on the wiki for the first-time setup.
Mods/*
Mono.Cecil.dll
Newtonsoft.Json.dll
StardewModdingAPI.AssemblyRewriters.dll
StardewModdingAPI.config.json
StardewModdingAPI.Internal.dll
StardewModdingAPI.metadata.json
StardewModdingAPI.exe
StardewModdingAPI.pdb
StardewModdingAPI.xml
@ -135,7 +137,6 @@ change without warning.
argument | purpose
-------- | -------
`--log-path "path"` | The relative or absolute path of the log file SMAPI should write.
`--no-terminal` | SMAPI won't write anything to the console window. (Messages will still be written to the log file.)
### Compile flags
@ -157,48 +158,66 @@ persisted in a compressed form to Pastebin.
The log parser lives at https://log.smapi.io.
### Mods API
The mods API provides version info for mods hosted by Chucklefish, GitHub, or Nexus Mods. It's used
by SMAPI to perform update checks. The `{version}` URL token is the version of SMAPI making the
request; it doesn't do anything currently, but lets us version breaking changes if needed.
### Web API
SMAPI provides a web API at `api.smapi.io` for use by SMAPI and external tools. The URL includes a
`{version}` token, which is the SMAPI version for backwards compatibility. This API is publicly
accessible but not officially released; it may change at any time.
Each mod is identified by a repository key and unique identifier (like `nexus:541`). The following
repositories are supported:
The API has one `/mods` endpoint. This provides mod info, including official versions and URLs
(from Chucklefish, GitHub, or Nexus), unofficial versions from the wiki, and optional mod metadata
from the wiki and SMAPI's internal data. This is used by SMAPI to perform update checks, and by
external tools to fetch mod data.
key | repository
------------- | ----------
`chucklefish` | A mod page on the [Chucklefish mod site](https://community.playstarbound.com/resources/categories/22), identified by the mod ID in the page URL.
`github` | A repository on [GitHub](https://github.com), identified by its owner and repository name (like `Zoryn4163/SMAPI-Mods`). This checks the version of the latest repository release.
`nexus` | A mod page on [Nexus Mods](https://www.nexusmods.com/stardewvalley), identified by the mod ID in the page URL.
The API accepts a `POST` request with the mods to match, each of which **must** specify an ID and
may _optionally_ specify [update keys](https://stardewvalleywiki.com/Modding:Modder_Guide/APIs/Manifest#Update_checks).
The API will automatically try to fetch known update keys from the wiki and internal data based on
the given ID.
```
POST https://api.smapi.io/v2.0/mods
{
"mods": [
{
"id": "Pathoschild.LookupAnything",
"updateKeys": [ "nexus:541", "chucklefish:4250" ]
}
],
"includeExtendedMetadata": true
}
```
The API accepts either `GET` or `POST` for convenience:
> ```
>GET https://api.smapi.io/v2.0/mods?modKeys=nexus:541,chucklefish:4228
>```
The API will automatically aggregate versions and errors. Each mod will include...
* an `id` (matching what you passed in);
* up to three versions: `main` (e.g. 'latest version' field on Nexus), `optional` if newer (e.g.
optional files on Nexus), and `unofficial` if newer (from the wiki);
* `metadata` with mod info crossreferenced from the wiki and internal data (only if you specified
`includeExtendedMetadata: true`);
* and `errors` containing any error messages that occurred while fetching data.
>```
>POST https://api.smapi.io/v2.0/mods
>{
> "ModKeys": [ "nexus:541", "chucklefish:4228" ]
>}
>```
It returns a response like this:
>```
>{
> "chucklefish:4228": {
> "name": "Entoarox Framework",
> "version": "1.8.0",
> "url": "https://community.playstarbound.com/resources/4228"
> },
> "nexus:541": {
> "name": "Lookup Anything",
> "version": "1.16",
> "url": "http://www.nexusmods.com/stardewvalley/mods/541"
> }
>}
>```
For example:
```
[
{
"id": "Pathoschild.LookupAnything",
"main": {
"version": "1.19",
"url": "https://www.nexusmods.com/stardewvalley/mods/541"
},
"metadata": {
"id": [
"Pathoschild.LookupAnything",
"LookupAnything"
],
"name": "Lookup Anything",
"nexusID": 541,
"gitHubRepo": "Pathoschild/StardewMods",
"compatibilityStatus": "Ok",
"compatibilitySummary": "✓ use latest version."
},
"errors": []
}
]
```
## Development
### Local development

View File

@ -1,4 +0,0 @@
using System.Reflection;
[assembly: AssemblyTitle("SMAPI.AssemblyRewriters")]
[assembly: AssemblyDescription("Contains internal SMAPI classes used during assembly rewriting that need to be public for technical reasons, but shouldn't be visible to modders.")]

View File

@ -1,37 +0,0 @@
using System.Collections.Generic;
using System.Linq;
namespace StardewModdingAPI.Common.Models
{
/// <summary>Specifies mods whose update-check info to fetch.</summary>
internal class ModSearchModel
{
/*********
** Accessors
*********/
/// <summary>The namespaced mod keys to search.</summary>
public string[] ModKeys { get; set; }
/// <summary>Whether to allow non-semantic versions, instead of returning an error for those.</summary>
public bool AllowInvalidVersions { get; set; }
/*********
** Public methods
*********/
/// <summary>Construct an empty instance.</summary>
public ModSearchModel()
{
// needed for JSON deserialising
}
/// <summary>Construct an instance.</summary>
/// <param name="modKeys">The namespaced mod keys to search.</param>
/// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param>
public ModSearchModel(IEnumerable<string> modKeys, bool allowInvalidVersions)
{
this.ModKeys = modKeys.ToArray();
this.AllowInvalidVersions = allowInvalidVersions;
}
}
}

View File

@ -1,199 +0,0 @@
using System;
using System.Text.RegularExpressions;
namespace StardewModdingAPI.Common
{
/// <summary>A low-level implementation of a semantic version with an optional release tag.</summary>
/// <remarks>The implementation is defined by Semantic Version 2.0 (http://semver.org/).</remarks>
internal class SemanticVersionImpl
{
/*********
** Accessors
*********/
/// <summary>The major version incremented for major API changes.</summary>
public int Major { get; }
/// <summary>The minor version incremented for backwards-compatible changes.</summary>
public int Minor { get; }
/// <summary>The patch version for backwards-compatible bug fixes.</summary>
public int Patch { get; }
/// <summary>An optional prerelease tag.</summary>
public string Tag { get; }
/// <summary>A regex pattern matching a version within a larger string.</summary>
internal const string UnboundedVersionPattern = @"(?>(?<major>0|[1-9]\d*))\.(?>(?<minor>0|[1-9]\d*))(?>(?:\.(?<patch>0|[1-9]\d*))?)(?:-(?<prerelease>(?>[a-z0-9]+[\-\.]?)+))?";
/// <summary>A regular expression matching a semantic version string.</summary>
/// <remarks>
/// This pattern is derived from the BNF documentation in the <a href="https://github.com/mojombo/semver">semver repo</a>,
/// with three important deviations intended to support Stardew Valley mod conventions:
/// - allows short-form "x.y" versions;
/// - allows hyphens in prerelease tags as synonyms for dots (like "-unofficial-update.3");
/// - doesn't allow '+build' suffixes.
/// </remarks>
internal static readonly Regex Regex = new Regex($@"^{SemanticVersionImpl.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="major">The major version incremented for major API changes.</param>
/// <param name="minor">The minor version incremented for backwards-compatible changes.</param>
/// <param name="patch">The patch version for backwards-compatible bug fixes.</param>
/// <param name="tag">An optional prerelease tag.</param>
public SemanticVersionImpl(int major, int minor, int patch, string tag = null)
{
this.Major = major;
this.Minor = minor;
this.Patch = patch;
this.Tag = this.GetNormalisedTag(tag);
}
/// <summary>Construct an instance.</summary>
/// <param name="version">The assembly version.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
public SemanticVersionImpl(Version version)
{
if (version == null)
throw new ArgumentNullException(nameof(version), "The input version can't be null.");
this.Major = version.Major;
this.Minor = version.Minor;
this.Patch = version.Build;
}
/// <summary>Construct an instance.</summary>
/// <param name="version">The semantic version string.</param>
/// <exception cref="ArgumentNullException">The <paramref name="version"/> is null.</exception>
/// <exception cref="FormatException">The <paramref name="version"/> is not a valid semantic version.</exception>
public SemanticVersionImpl(string version)
{
// parse
if (version == null)
throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
var match = SemanticVersionImpl.Regex.Match(version.Trim());
if (!match.Success)
throw new FormatException($"The input '{version}' isn't a valid semantic version.");
// initialise
this.Major = int.Parse(match.Groups["major"].Value);
this.Minor = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
this.Patch = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
this.Tag = match.Groups["prerelease"].Success ? this.GetNormalisedTag(match.Groups["prerelease"].Value) : null;
}
/// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
/// <param name="other">The version to compare with this instance.</param>
/// <exception cref="ArgumentNullException">The <paramref name="other"/> value is null.</exception>
public int CompareTo(SemanticVersionImpl other)
{
if (other == null)
throw new ArgumentNullException(nameof(other));
return this.CompareTo(other.Major, other.Minor, other.Patch, other.Tag);
}
/// <summary>Get an integer indicating whether this version precedes (less than 0), supercedes (more than 0), or is equivalent to (0) the specified version.</summary>
/// <param name="otherMajor">The major version to compare with this instance.</param>
/// <param name="otherMinor">The minor version to compare with this instance.</param>
/// <param name="otherPatch">The patch version to compare with this instance.</param>
/// <param name="otherTag">The prerelease tag to compare with this instance.</param>
public int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag)
{
const int same = 0;
const int curNewer = 1;
const int curOlder = -1;
// compare stable versions
if (this.Major != otherMajor)
return this.Major.CompareTo(otherMajor);
if (this.Minor != otherMinor)
return this.Minor.CompareTo(otherMinor);
if (this.Patch != otherPatch)
return this.Patch.CompareTo(otherPatch);
if (this.Tag == otherTag)
return same;
// stable supercedes pre-release
bool curIsStable = string.IsNullOrWhiteSpace(this.Tag);
bool otherIsStable = string.IsNullOrWhiteSpace(otherTag);
if (curIsStable)
return curNewer;
if (otherIsStable)
return curOlder;
// compare two pre-release tag values
string[] curParts = this.Tag.Split('.', '-');
string[] otherParts = otherTag.Split('.', '-');
for (int i = 0; i < curParts.Length; i++)
{
// longer prerelease tag supercedes if otherwise equal
if (otherParts.Length <= i)
return curNewer;
// compare if different
if (curParts[i] != otherParts[i])
{
// compare numerically if possible
{
if (int.TryParse(curParts[i], out int curNum) && int.TryParse(otherParts[i], out int otherNum))
return curNum.CompareTo(otherNum);
}
// else compare lexically
return string.Compare(curParts[i], otherParts[i], StringComparison.OrdinalIgnoreCase);
}
}
// fallback (this should never happen)
return string.Compare(this.ToString(), new SemanticVersionImpl(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
}
/// <summary>Get a string representation of the version.</summary>
public override string ToString()
{
// version
string result = this.Patch != 0
? $"{this.Major}.{this.Minor}.{this.Patch}"
: $"{this.Major}.{this.Minor}";
// tag
string tag = this.Tag;
if (tag != null)
result += $"-{tag}";
return result;
}
/// <summary>Parse a version string without throwing an exception if it fails.</summary>
/// <param name="version">The version string.</param>
/// <param name="parsed">The parsed representation.</param>
/// <returns>Returns whether parsing the version succeeded.</returns>
internal static bool TryParse(string version, out SemanticVersionImpl parsed)
{
try
{
parsed = new SemanticVersionImpl(version);
return true;
}
catch
{
parsed = null;
return false;
}
}
/*********
** Private methods
*********/
/// <summary>Get a normalised build tag.</summary>
/// <param name="tag">The tag to normalise.</param>
private string GetNormalisedTag(string tag)
{
tag = tag?.Trim();
return !string.IsNullOrWhiteSpace(tag) ? tag : null;
}
}
}

View File

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<HasSharedItems>true</HasSharedItems>
<SharedGUID>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</SharedGUID>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<Import_RootNamespace>StardewModdingAPI.Common</Import_RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)Models\ModSeachModel.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Models\ModInfoModel.cs" />
<Compile Include="$(MSBuildThisFileDirectory)SemanticVersionImpl.cs" />
</ItemGroup>
<ItemGroup>
<Folder Include="$(MSBuildThisFileDirectory)Models\" />
</ItemGroup>
</Project>

View File

@ -1,12 +0,0 @@
namespace StardewModdingApi.Installer.Enums
{
/// <summary>The game's platform version.</summary>
internal enum Platform
{
/// <summary>The Linux/Mac version of the game.</summary>
Mono,
/// <summary>The Windows version of the game.</summary>
Windows
}
}

View File

@ -7,7 +7,8 @@ using System.Reflection;
using System.Threading;
using Microsoft.Win32;
using StardewModdingApi.Installer.Enums;
using StardewModdingAPI.Common;
using StardewModdingAPI.Internal;
using StardewModdingAPI.Internal.ConsoleWriting;
namespace StardewModdingApi.Installer
{
@ -17,6 +18,15 @@ namespace StardewModdingApi.Installer
/*********
** Properties
*********/
/// <summary>The name of the installer file in the package.</summary>
private readonly string InstallerFileName = "install.exe";
/// <summary>Mod files which shouldn't be deleted when deploying bundled mods (mod folder name => file names).</summary>
private readonly IDictionary<string, HashSet<string>> ProtectBundledFiles = new Dictionary<string, HashSet<string>>(StringComparer.InvariantCultureIgnoreCase)
{
["SaveBackup"] = new HashSet<string>(new[] { "backups", "config.json" }, StringComparer.InvariantCultureIgnoreCase)
};
/// <summary>The <see cref="Environment.OSVersion"/> value that represents Windows 7.</summary>
private readonly Version Windows7Version = new Version(6, 1);
@ -27,7 +37,8 @@ namespace StardewModdingApi.Installer
{
switch (platform)
{
case Platform.Mono:
case Platform.Linux:
case Platform.Mac:
{
string home = Environment.GetEnvironmentVariable("HOME");
@ -61,6 +72,11 @@ namespace StardewModdingApi.Installer
if (!string.IsNullOrWhiteSpace(path))
yield return path;
}
// via Steam library path
string steampath = this.GetCurrentUserRegistryValue(@"Software\Valve\Steam", "SteamPath");
if (steampath != null)
yield return Path.Combine(steampath.Replace('/', '\\'), @"steamapps\common\Stardew Valley");
}
break;
@ -77,12 +93,20 @@ namespace StardewModdingApi.Installer
string GetInstallPath(string path) => Path.Combine(installDir.FullName, path);
// common
yield return GetInstallPath("0Harmony.dll");
yield return GetInstallPath("0Harmony.pdb");
yield return GetInstallPath("Mono.Cecil.dll");
yield return GetInstallPath("Newtonsoft.Json.dll");
yield return GetInstallPath("StardewModdingAPI.exe");
yield return GetInstallPath("StardewModdingAPI.config.json");
yield return GetInstallPath("StardewModdingAPI.data.json");
yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll");
yield return GetInstallPath("StardewModdingAPI.metadata.json");
yield return GetInstallPath("StardewModdingAPI.Toolkit.dll");
yield return GetInstallPath("StardewModdingAPI.Toolkit.pdb");
yield return GetInstallPath("StardewModdingAPI.Toolkit.xml");
yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.dll");
yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.pdb");
yield return GetInstallPath("StardewModdingAPI.Toolkit.CoreInterfaces.xml");
yield return GetInstallPath("StardewModdingAPI.xml");
yield return GetInstallPath("System.ValueTuple.dll");
yield return GetInstallPath("steam_appid.txt");
@ -101,6 +125,7 @@ namespace StardewModdingApi.Installer
yield return GetInstallPath(Path.Combine("Mods", "TrainerMod")); // *2.0 (renamed to ConsoleCommands)
yield return GetInstallPath("Mono.Cecil.Rocks.dll"); // 1.31.8
yield return GetInstallPath("StardewModdingAPI-settings.json"); // 1.0-1.4
yield return GetInstallPath("StardewModdingAPI.AssemblyRewriters.dll"); // 1.3-2.5.5
if (modsDir.Exists)
{
foreach (DirectoryInfo modDir in modsDir.EnumerateDirectories())
@ -109,24 +134,30 @@ namespace StardewModdingApi.Installer
yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley", "ErrorLogs"); // remove old log files
}
/// <summary>Whether the current console supports color formatting.</summary>
private static readonly bool ConsoleSupportsColor = InteractiveInstaller.GetConsoleSupportsColor();
/// <summary>Handles writing color-coded text to the console.</summary>
private readonly ColorfulConsoleWriter ConsoleWriter;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public InteractiveInstaller()
{
this.ConsoleWriter = new ColorfulConsoleWriter(EnvironmentUtility.DetectPlatform(), MonitorColorScheme.AutoDetect);
}
/// <summary>Run the install or uninstall script.</summary>
/// <param name="args">The command line arguments.</param>
/// <remarks>
/// Initialisation flow:
/// 1. Collect information (mainly OS and install path) and validate it.
/// 2. Ask the user whether to install or uninstall.
///
///
/// Uninstall logic:
/// 1. On Linux/Mac: if a backup of the launcher exists, delete the launcher and restore the backup.
/// 2. Delete all files and folders in the game directory matching one of the values returned by <see cref="GetUninstallPaths"/>.
///
///
/// Install flow:
/// 1. Run the uninstall flow.
/// 2. Copy the SMAPI files from package/Windows or package/Mono into the game directory.
@ -140,10 +171,19 @@ namespace StardewModdingApi.Installer
/****
** Get platform & set window title
****/
Platform platform = this.DetectPlatform();
Console.Title = $"SMAPI {new SemanticVersionImpl(this.GetType().Assembly.GetName().Version)} installer on {platform}";
Platform platform = EnvironmentUtility.DetectPlatform();
Console.Title = $"SMAPI {this.GetDisplayVersion(this.GetType().Assembly.GetName().Version)} installer on {platform} {EnvironmentUtility.GetFriendlyPlatformName(platform)}";
Console.WriteLine();
#if SMAPI_FOR_WINDOWS
if (platform == Platform.Linux || platform == Platform.Mac)
{
this.PrintError($"This is the installer for Windows. Run the 'install on {platform}.{(platform == Platform.Linux ? "sh" : "command")}' file instead.");
Console.ReadLine();
return;
}
#endif
/****
** read command-line arguments
****/
@ -178,18 +218,20 @@ namespace StardewModdingApi.Installer
}
// get folders
DirectoryInfo packageDir = new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", platform.ToString()));
DirectoryInfo packageDir = platform.IsMono()
? new DirectoryInfo(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)) // installer runs from internal folder on Mono
: new DirectoryInfo(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "internal", "Windows"));
DirectoryInfo modsDir = new DirectoryInfo(Path.Combine(installDir.FullName, "Mods"));
var paths = new
{
executable = Path.Combine(installDir.FullName, platform == Platform.Mono ? "StardewValley.exe" : "Stardew Valley.exe"),
executable = Path.Combine(installDir.FullName, EnvironmentUtility.GetExecutableName(platform)),
unixSmapiLauncher = Path.Combine(installDir.FullName, "StardewModdingAPI"),
unixLauncher = Path.Combine(installDir.FullName, "StardewValley"),
unixLauncherBackup = Path.Combine(installDir.FullName, "StardewValley-original")
};
// show output
Console.WriteLine($"Your game folder: {installDir}.");
this.PrintInfo($"Your game folder: {installDir}.");
/****
** validate assumptions
@ -198,7 +240,7 @@ namespace StardewModdingApi.Installer
{
this.PrintError(platform == Platform.Windows && packageDir.FullName.Contains(Path.GetTempPath()) && packageDir.FullName.Contains(".zip")
? "The installer is missing some files. It looks like you're running the installer from inside the downloaded zip; make sure you unzip the downloaded file first, then run the installer from the unzipped folder."
: $"The 'internal/{platform}' package folder is missing (should be at {packageDir})."
: $"The 'internal/{packageDir.Name}' package folder is missing (should be at {packageDir})."
);
Console.ReadLine();
return;
@ -226,7 +268,7 @@ namespace StardewModdingApi.Installer
Console.ReadLine();
return;
}
if (!this.HasXNA(platform))
if (!this.HasXna(platform))
{
this.PrintError("You don't seem to have XNA Framework installed. Please run the game at least once before installing SMAPI, so it can perform its first-time setup.");
Console.ReadLine();
@ -247,9 +289,9 @@ namespace StardewModdingApi.Installer
action = ScriptAction.Uninstall;
else
{
Console.WriteLine("You can....");
Console.WriteLine("[1] Install SMAPI.");
Console.WriteLine("[2] Uninstall SMAPI.");
this.PrintInfo("You can....");
this.PrintInfo("[1] Install SMAPI.");
this.PrintInfo("[2] Uninstall SMAPI.");
Console.WriteLine();
string choice = this.InteractivelyChoose("What do you want to do? Type 1 or 2, then press enter.", "1", "2");
@ -271,7 +313,7 @@ namespace StardewModdingApi.Installer
** Always uninstall old files
****/
// restore game launcher
if (platform == Platform.Mono && File.Exists(paths.unixLauncherBackup))
if (platform.IsMono() && File.Exists(paths.unixLauncherBackup))
{
this.PrintDebug("Removing SMAPI launcher...");
this.InteractivelyDelete(paths.unixLauncher);
@ -298,19 +340,25 @@ namespace StardewModdingApi.Installer
this.PrintDebug("Adding SMAPI files...");
foreach (FileInfo sourceFile in packageDir.EnumerateFiles().Where(this.ShouldCopyFile))
{
if (sourceFile.Name == this.InstallerFileName)
continue;
string targetPath = Path.Combine(installDir.FullName, sourceFile.Name);
this.InteractivelyDelete(targetPath);
sourceFile.CopyTo(targetPath);
}
// replace mod launcher (if possible)
if (platform == Platform.Mono)
if (platform.IsMono())
{
this.PrintDebug("Safely replacing game launcher...");
if (!File.Exists(paths.unixLauncherBackup))
File.Move(paths.unixLauncher, paths.unixLauncherBackup);
else if (File.Exists(paths.unixLauncher))
this.InteractivelyDelete(paths.unixLauncher);
if (File.Exists(paths.unixLauncher))
{
if (!File.Exists(paths.unixLauncherBackup))
File.Move(paths.unixLauncher, paths.unixLauncherBackup);
else
this.InteractivelyDelete(paths.unixLauncher);
}
File.Move(paths.unixSmapiLauncher, paths.unixLauncher);
}
@ -323,19 +371,42 @@ namespace StardewModdingApi.Installer
}
// add or replace bundled mods
Directory.CreateDirectory(Path.Combine(installDir.FullName, "Mods"));
modsDir.Create();
DirectoryInfo packagedModsDir = new DirectoryInfo(Path.Combine(packageDir.FullName, "Mods"));
if (packagedModsDir.Exists && packagedModsDir.EnumerateDirectories().Any())
{
this.PrintDebug("Adding bundled mods...");
// special case: rename Omegasis' SaveBackup mod
{
DirectoryInfo oldFolder = new DirectoryInfo(Path.Combine(modsDir.FullName, "SaveBackup"));
DirectoryInfo newFolder = new DirectoryInfo(Path.Combine(modsDir.FullName, "AdvancedSaveBackup"));
FileInfo manifest = new FileInfo(Path.Combine(oldFolder.FullName, "manifest.json"));
if (manifest.Exists && !newFolder.Exists && File.ReadLines(manifest.FullName).Any(p => p.IndexOf("Omegasis", StringComparison.InvariantCultureIgnoreCase) != -1))
{
this.PrintDebug($" moving {oldFolder.Name} to {newFolder.Name}...");
this.Move(oldFolder, newFolder.FullName);
}
}
// add bundled mods
foreach (DirectoryInfo sourceDir in packagedModsDir.EnumerateDirectories())
{
this.PrintDebug($" adding {sourceDir.Name}...");
// initialise target dir
// init/clear target dir
DirectoryInfo targetDir = new DirectoryInfo(Path.Combine(modsDir.FullName, sourceDir.Name));
this.InteractivelyDelete(targetDir.FullName);
targetDir.Create();
if (targetDir.Exists)
{
this.ProtectBundledFiles.TryGetValue(targetDir.Name, out HashSet<string> protectedFiles);
foreach (FileSystemInfo entry in targetDir.EnumerateFileSystemInfos())
{
if (protectedFiles == null || !protectedFiles.Contains(entry.Name))
this.InteractivelyDelete(entry.FullName);
}
}
else
targetDir.Create();
// copy files
foreach (FileInfo sourceFile in sourceDir.EnumerateFiles().Where(this.ShouldCopyFile))
@ -344,7 +415,7 @@ namespace StardewModdingApi.Installer
}
// remove obsolete appdata mods
this.InteractivelyRemoveAppDataMods(platform, modsDir, packagedModsDir);
this.InteractivelyRemoveAppDataMods(modsDir, packagedModsDir);
}
Console.WriteLine();
Console.WriteLine();
@ -356,20 +427,20 @@ namespace StardewModdingApi.Installer
{
if (action == ScriptAction.Install)
{
this.PrintColor("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):", ConsoleColor.DarkGreen);
this.PrintColor($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%", ConsoleColor.DarkGreen);
this.PrintSuccess("SMAPI is installed! If you use Steam, set your launch options to enable achievements (see smapi.io/install):");
this.PrintSuccess($" \"{Path.Combine(installDir.FullName, "StardewModdingAPI.exe")}\" %command%");
Console.WriteLine();
this.PrintColor("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods.", ConsoleColor.DarkGreen);
this.PrintSuccess("If you don't use Steam, launch StardewModdingAPI.exe in your game folder to play with mods.");
}
else
this.PrintColor("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options.", ConsoleColor.DarkGreen);
this.PrintSuccess("SMAPI is removed! If you configured Steam to launch SMAPI, don't forget to clear your launch options.");
}
else
{
if (action == ScriptAction.Install)
this.PrintColor("SMAPI is installed! Launch the game the same way as before to play with mods.", ConsoleColor.DarkGreen);
else
this.PrintColor("SMAPI is removed! Launch the game the same way as before to play without mods.", ConsoleColor.DarkGreen);
this.PrintSuccess(action == ScriptAction.Install
? "SMAPI is installed! Launch the game the same way as before to play with mods."
: "SMAPI is removed! Launch the game the same way as before to play without mods."
);
}
Console.ReadKey();
@ -379,36 +450,17 @@ namespace StardewModdingApi.Installer
/*********
** Private methods
*********/
/// <summary>Detect the game's platform.</summary>
/// <exception cref="NotSupportedException">The platform is not supported.</exception>
private Platform DetectPlatform()
/// <summary>Get the display text for an assembly version.</summary>
/// <param name="version">The assembly version.</param>
private string GetDisplayVersion(Version version)
{
switch (Environment.OSVersion.Platform)
{
case PlatformID.MacOSX:
case PlatformID.Unix:
return Platform.Mono;
default:
return Platform.Windows;
}
string str = $"{version.Major}.{version.Minor}";
if (version.Build != 0)
str += $".{version.Build}";
return str;
}
/// <summary>Test whether the current console supports color formatting.</summary>
private static bool GetConsoleSupportsColor()
{
try
{
Console.ForegroundColor = Console.ForegroundColor;
return true;
}
catch (Exception)
{
return false; // Mono bug
}
}
/// <summary>Get the value of a key in the Windows registry.</summary>
/// <summary>Get the value of a key in the Windows HKLM registry.</summary>
/// <param name="key">The full path of the registry key relative to HKLM.</param>
/// <param name="name">The name of the value.</param>
private string GetLocalMachineRegistryValue(string key, string name)
@ -421,41 +473,38 @@ namespace StardewModdingApi.Installer
return (string)openKey.GetValue(name);
}
/// <summary>Get the value of a key in the Windows HKCU registry.</summary>
/// <param name="key">The full path of the registry key relative to HKCU.</param>
/// <param name="name">The name of the value.</param>
private string GetCurrentUserRegistryValue(string key, string name)
{
RegistryKey currentuser = Environment.Is64BitOperatingSystem ? RegistryKey.OpenBaseKey(RegistryHive.CurrentUser, RegistryView.Registry64) : Registry.CurrentUser;
RegistryKey openKey = currentuser.OpenSubKey(key);
if (openKey == null)
return null;
using (openKey)
return (string)openKey.GetValue(name);
}
/// <summary>Print a debug message.</summary>
/// <param name="text">The text to print.</param>
private void PrintDebug(string text)
{
this.PrintColor(text, ConsoleColor.DarkGray);
}
private void PrintDebug(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Debug);
/// <summary>Print a debug message.</summary>
/// <param name="text">The text to print.</param>
private void PrintInfo(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Info);
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
private void PrintWarning(string text)
{
this.PrintColor(text, ConsoleColor.DarkYellow);
}
private void PrintWarning(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Warn);
/// <summary>Print a warning message.</summary>
/// <param name="text">The text to print.</param>
private void PrintError(string text)
{
this.PrintColor(text, ConsoleColor.Red);
}
private void PrintError(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Error);
/// <summary>Print a message to the console.</summary>
/// <param name="text">The message text.</param>
/// <param name="color">The text foreground color.</param>
private void PrintColor(string text, ConsoleColor color)
{
if (InteractiveInstaller.ConsoleSupportsColor)
{
Console.ForegroundColor = color;
Console.WriteLine(text);
Console.ResetColor();
}
else
Console.WriteLine(text);
}
/// <summary>Print a success message.</summary>
/// <param name="text">The text to print.</param>
private void PrintSuccess(string text) => this.ConsoleWriter.WriteLine(text, ConsoleLogLevel.Success);
/// <summary>Get whether the current system has .NET Framework 4.5 or later installed. This only applies on Windows.</summary>
/// <param name="platform">The current platform.</param>
@ -476,7 +525,7 @@ namespace StardewModdingApi.Installer
/// <summary>Get whether the current system has XNA Framework installed. This only applies on Windows.</summary>
/// <param name="platform">The current platform.</param>
/// <exception cref="NotSupportedException">The current platform is not Windows.</exception>
private bool HasXNA(Platform platform)
private bool HasXna(Platform platform)
{
switch (platform)
{
@ -511,6 +560,7 @@ namespace StardewModdingApi.Installer
/// <summary>Delete a file or folder regardless of file permissions, and block until deletion completes.</summary>
/// <param name="entry">The file or folder to reset.</param>
/// <remarks>This method is mirred from <c>FileUtilities.ForceDelete</c> in the toolkit.</remarks>
private void ForceDelete(FileSystemInfo entry)
{
// ignore if already deleted
@ -519,8 +569,7 @@ namespace StardewModdingApi.Installer
return;
// delete children
var folder = entry as DirectoryInfo;
if (folder != null)
if (entry is DirectoryInfo folder)
{
foreach (FileSystemInfo child in folder.GetFileSystemInfos())
this.ForceDelete(child);
@ -551,11 +600,11 @@ namespace StardewModdingApi.Installer
{
while (true)
{
Console.WriteLine(message);
this.PrintInfo(message);
string input = Console.ReadLine()?.Trim().ToLowerInvariant();
if (!options.Contains(input))
{
Console.WriteLine("That's not a valid option.");
this.PrintInfo("That's not a valid option.");
continue;
}
return input;
@ -568,9 +617,7 @@ namespace StardewModdingApi.Installer
private DirectoryInfo InteractivelyGetInstallPath(Platform platform, string specifiedPath)
{
// get executable name
string executableFilename = platform == Platform.Windows
? "Stardew Valley.exe"
: "StardewValley.exe";
string executableFilename = EnvironmentUtility.GetExecutableName(platform);
// validate specified path
if (specifiedPath != null)
@ -597,6 +644,8 @@ namespace StardewModdingApi.Installer
where dir.Exists && dir.EnumerateFiles(executableFilename).Any()
select dir
)
.GroupBy(p => p.FullName, StringComparer.InvariantCultureIgnoreCase) // ignore duplicate paths
.Select(p => p.First())
.ToArray();
// choose where to install
@ -608,9 +657,9 @@ namespace StardewModdingApi.Installer
// let user choose path
Console.WriteLine();
Console.WriteLine("Found multiple copies of the game:");
this.PrintInfo("Found multiple copies of the game:");
for (int i = 0; i < defaultPaths.Length; i++)
Console.WriteLine($"[{i + 1}] {defaultPaths[i].FullName}");
this.PrintInfo($"[{i + 1}] {defaultPaths[i].FullName}");
Console.WriteLine();
string[] validOptions = Enumerable.Range(1, defaultPaths.Length).Select(p => p.ToString(CultureInfo.InvariantCulture)).ToArray();
@ -620,22 +669,22 @@ namespace StardewModdingApi.Installer
}
// ask user
Console.WriteLine("Oops, couldn't find the game automatically.");
this.PrintInfo("Oops, couldn't find the game automatically.");
while (true)
{
// get path from user
Console.WriteLine($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter.");
this.PrintInfo($"Type the file path to the game directory (the one containing '{executableFilename}'), then press enter.");
string path = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(path))
{
Console.WriteLine(" You must specify a directory path to continue.");
this.PrintInfo(" You must specify a directory path to continue.");
continue;
}
// normalise path
if (platform == Platform.Windows)
path = path.Replace("\"", ""); // in Windows, quotes are used to escape spaces and aren't part of the file path
if (platform == Platform.Mono)
if (platform == Platform.Linux || platform == Platform.Mac)
path = path.Replace("\\ ", " "); // in Linux/Mac, spaces in paths may be escaped if copied from the command line
if (path.StartsWith("~/"))
{
@ -651,35 +700,31 @@ namespace StardewModdingApi.Installer
// validate path
if (!directory.Exists)
{
Console.WriteLine(" That directory doesn't seem to exist.");
this.PrintInfo(" That directory doesn't seem to exist.");
continue;
}
if (!directory.EnumerateFiles(executableFilename).Any())
{
Console.WriteLine(" That directory doesn't contain a Stardew Valley executable.");
this.PrintInfo(" That directory doesn't contain a Stardew Valley executable.");
continue;
}
// looks OK
Console.WriteLine(" OK!");
this.PrintInfo(" OK!");
return directory;
}
}
/// <summary>Interactively move mods out of the appdata directory.</summary>
/// <param name="platform">The current platform.</param>
/// <param name="properModsDir">The directory which should contain all mods.</param>
/// <param name="packagedModsDir">The installer directory containing packaged mods.</param>
private void InteractivelyRemoveAppDataMods(Platform platform, DirectoryInfo properModsDir, DirectoryInfo packagedModsDir)
private void InteractivelyRemoveAppDataMods(DirectoryInfo properModsDir, DirectoryInfo packagedModsDir)
{
// get packaged mods to delete
string[] packagedModNames = packagedModsDir.GetDirectories().Select(p => p.Name).ToArray();
// get path
string homePath = platform == Platform.Windows
? Environment.GetEnvironmentVariable("APPDATA")
: Path.Combine(Environment.GetEnvironmentVariable("HOME"), ".config");
string appDataPath = Path.Combine(homePath, "StardewValley");
string appDataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
DirectoryInfo modDir = new DirectoryInfo(Path.Combine(appDataPath, "Mods"));
// check if migration needed

View File

@ -9,7 +9,7 @@
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>StardewModdingAPI.Installer</RootNamespace>
<AssemblyName>StardewModdingAPI.Installer</AssemblyName>
<TargetFrameworkVersion>v4.0</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
@ -40,7 +40,6 @@
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Enums\ScriptAction.cs" />
<Compile Include="Enums\Platform.cs" />
<Compile Include="InteractiveInstaller.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
@ -58,7 +57,7 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-install-package.targets" />

View File

@ -14,15 +14,9 @@
SMAPI lets you run Stardew Valley with mods. Don't forget to download mods separately.
Install guide
Player's guide
--------------------------------
See http://stardewvalleywiki.com/Modding:Installing_SMAPI.
Need help?
--------------------------------
- FAQs: http://stardewvalleywiki.com/Modding:Player_FAQs
- Ask for help: https://discord.gg/kH55QXP
See https://stardewvalleywiki.com/Modding:Player_Guide
Manual install
@ -30,7 +24,7 @@ Manual install
THIS IS NOT RECOMMENDED FOR MOST PLAYERS. See instructions above instead.
If you really want to install SMAPI manually, here's how.
1. Download the latest version of SMAPI: https://github.com/Pathoschild/SMAPI/releases.
1. Download the latest version of SMAPI: https://github.com/Pathoschild/SMAPI/releases
2. Unzip the .zip file somewhere (not in your game folder).
3. Copy the files from the "internal/Windows" folder (on Windows) or "internal/Mono" folder (on
Linux/Mac) into your game folder. The `StardewModdingAPI.exe` file should be right next to the

View File

@ -14,7 +14,7 @@ fi
# validate Mono & run installer
if $COMMAND mono >/dev/null 2>&1; then
mono install.exe
mono internal/Mono/install.exe
else
echo "Oops! Looks like Mono isn't installed. Please install Mono from http://mono-project.com, reboot, and run this installer again."
read

View File

@ -74,11 +74,11 @@ else
elif $COMMAND xterm 2>/dev/null; then
xterm -e "$LAUNCHER"
elif $COMMAND xfce4-terminal 2>/dev/null; then
xfce4-terminal -e "$LAUNCHER"
xfce4-terminal -e "env TERM=xterm; $LAUNCHER"
elif $COMMAND gnome-terminal 2>/dev/null; then
gnome-terminal -e "$LAUNCHER"
gnome-terminal -e "env TERM=xterm; $LAUNCHER"
elif $COMMAND konsole 2>/dev/null; then
konsole -e "$LAUNCHER"
konsole -p Environment=TERM=xterm -e "$LAUNCHER"
elif $COMMAND terminal 2>/dev/null; then
terminal -e "$LAUNCHER"
else

View File

@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
namespace StardewModdingAPI.Internal.ConsoleWriting
{
/// <summary>Provides a wrapper for writing color-coded text to the console.</summary>
internal class ColorfulConsoleWriter
{
/*********
** Properties
*********/
/// <summary>The console text color for each log level.</summary>
private readonly IDictionary<ConsoleLogLevel, ConsoleColor> Colors;
/// <summary>Whether the current console supports color formatting.</summary>
private readonly bool SupportsColor;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="platform">The target platform.</param>
/// <param name="colorScheme">The console color scheme to use.</param>
public ColorfulConsoleWriter(Platform platform, MonitorColorScheme colorScheme)
{
this.SupportsColor = this.TestColorSupport();
this.Colors = this.GetConsoleColorScheme(platform, colorScheme);
}
/// <summary>Write a message line to the log.</summary>
/// <param name="message">The message to log.</param>
/// <param name="level">The log level.</param>
public void WriteLine(string message, ConsoleLogLevel level)
{
if (this.SupportsColor)
{
if (level == ConsoleLogLevel.Critical)
{
Console.BackgroundColor = ConsoleColor.Red;
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine(message);
Console.ResetColor();
}
else
{
Console.ForegroundColor = this.Colors[level];
Console.WriteLine(message);
Console.ResetColor();
}
}
else
Console.WriteLine(message);
}
/*********
** Private methods
*********/
/// <summary>Test whether the current console supports color formatting.</summary>
private bool TestColorSupport()
{
try
{
Console.ForegroundColor = Console.ForegroundColor;
return true;
}
catch (Exception)
{
return false; // Mono bug
}
}
/// <summary>Get the color scheme to use for the current console.</summary>
/// <param name="platform">The target platform.</param>
/// <param name="colorScheme">The console color scheme to use.</param>
private IDictionary<ConsoleLogLevel, ConsoleColor> GetConsoleColorScheme(Platform platform, MonitorColorScheme colorScheme)
{
// auto detect color scheme
if (colorScheme == MonitorColorScheme.AutoDetect)
{
colorScheme = platform == Platform.Mac
? MonitorColorScheme.LightBackground // MacOS doesn't provide console background color info, but it's usually white.
: ColorfulConsoleWriter.IsDark(Console.BackgroundColor) ? MonitorColorScheme.DarkBackground : MonitorColorScheme.LightBackground;
}
// get colors for scheme
switch (colorScheme)
{
case MonitorColorScheme.DarkBackground:
return new Dictionary<ConsoleLogLevel, ConsoleColor>
{
[ConsoleLogLevel.Trace] = ConsoleColor.DarkGray,
[ConsoleLogLevel.Debug] = ConsoleColor.DarkGray,
[ConsoleLogLevel.Info] = ConsoleColor.White,
[ConsoleLogLevel.Warn] = ConsoleColor.Yellow,
[ConsoleLogLevel.Error] = ConsoleColor.Red,
[ConsoleLogLevel.Alert] = ConsoleColor.Magenta,
[ConsoleLogLevel.Success] = ConsoleColor.DarkGreen
};
case MonitorColorScheme.LightBackground:
return new Dictionary<ConsoleLogLevel, ConsoleColor>
{
[ConsoleLogLevel.Trace] = ConsoleColor.DarkGray,
[ConsoleLogLevel.Debug] = ConsoleColor.DarkGray,
[ConsoleLogLevel.Info] = ConsoleColor.Black,
[ConsoleLogLevel.Warn] = ConsoleColor.DarkYellow,
[ConsoleLogLevel.Error] = ConsoleColor.Red,
[ConsoleLogLevel.Alert] = ConsoleColor.DarkMagenta,
[ConsoleLogLevel.Success] = ConsoleColor.DarkGreen
};
default:
throw new NotSupportedException($"Unknown color scheme '{colorScheme}'.");
}
}
/// <summary>Get whether a console color should be considered dark, which is subjectively defined as 'white looks better than black on this text'.</summary>
/// <param name="color">The color to check.</param>
private static bool IsDark(ConsoleColor color)
{
switch (color)
{
case ConsoleColor.Black:
case ConsoleColor.Blue:
case ConsoleColor.DarkBlue:
case ConsoleColor.DarkMagenta: // Powershell
case ConsoleColor.DarkRed:
case ConsoleColor.Red:
return true;
default:
return false;
}
}
}
}

View File

@ -0,0 +1,30 @@
namespace StardewModdingAPI.Internal.ConsoleWriting
{
/// <summary>The log severity levels.</summary>
internal enum ConsoleLogLevel
{
/// <summary>Tracing info intended for developers.</summary>
Trace,
/// <summary>Troubleshooting info that may be relevant to the player.</summary>
Debug,
/// <summary>Info relevant to the player. This should be used judiciously.</summary>
Info,
/// <summary>An issue the player should be aware of. This should be used rarely.</summary>
Warn,
/// <summary>A message indicating something went wrong.</summary>
Error,
/// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
Alert,
/// <summary>A critical issue that generally signals an immediate end to the application.</summary>
Critical,
/// <summary>A success message that generally signals a successful end to a task.</summary>
Success
}
}

View File

@ -0,0 +1,15 @@
namespace StardewModdingAPI.Internal.ConsoleWriting
{
/// <summary>A monitor color scheme to use.</summary>
internal enum MonitorColorScheme
{
/// <summary>Choose a color scheme automatically.</summary>
AutoDetect,
/// <summary>Use lighter text colors that look better on a black or dark background.</summary>
DarkBackground,
/// <summary>Use darker text colors that look better on a white or light background.</summary>
LightBackground
}
}

View File

@ -0,0 +1,112 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
#if SMAPI_FOR_WINDOWS
using System.Management;
#endif
using System.Runtime.InteropServices;
namespace StardewModdingAPI.Internal
{
/// <summary>Provides methods for fetching environment information.</summary>
internal static class EnvironmentUtility
{
/*********
** Properties
*********/
/// <summary>Get the OS name from the system uname command.</summary>
/// <param name="buffer">The buffer to fill with the resulting string.</param>
[DllImport("libc")]
static extern int uname(IntPtr buffer);
/*********
** Public methods
*********/
/// <summary>Detect the current OS.</summary>
public static Platform DetectPlatform()
{
switch (Environment.OSVersion.Platform)
{
case PlatformID.MacOSX:
return Platform.Mac;
case PlatformID.Unix:
return EnvironmentUtility.IsRunningMac()
? Platform.Mac
: Platform.Linux;
default:
return Platform.Windows;
}
}
/// <summary>Get the human-readable OS name and version.</summary>
/// <param name="platform">The current platform.</param>
[SuppressMessage("ReSharper", "EmptyGeneralCatchClause", Justification = "Error suppressed deliberately to fallback to default behaviour.")]
public static string GetFriendlyPlatformName(Platform platform)
{
#if SMAPI_FOR_WINDOWS
try
{
return new ManagementObjectSearcher("SELECT Caption FROM Win32_OperatingSystem")
.Get()
.Cast<ManagementObject>()
.Select(entry => entry.GetPropertyValue("Caption").ToString())
.FirstOrDefault();
}
catch { }
#endif
return (platform == Platform.Mac ? "MacOS " : "") + Environment.OSVersion;
}
/// <summary>Get the name of the Stardew Valley executable.</summary>
/// <param name="platform">The current platform.</param>
public static string GetExecutableName(Platform platform)
{
return platform == Platform.Windows
? "Stardew Valley.exe"
: "StardewValley.exe";
}
/// <summary>Get whether the platform uses Mono.</summary>
/// <param name="platform">The current platform.</param>
public static bool IsMono(this Platform platform)
{
return platform == Platform.Linux || platform == Platform.Mac;
}
/*********
** Private methods
*********/
/// <summary>Detect whether the code is running on Mac.</summary>
/// <remarks>
/// This code is derived from the Mono project (see System.Windows.Forms/System.Windows.Forms/XplatUI.cs). It detects Mac by calling the
/// <c>uname</c> system command and checking the response, which is always 'Darwin' for MacOS.
/// </remarks>
private static bool IsRunningMac()
{
IntPtr buffer = IntPtr.Zero;
try
{
buffer = Marshal.AllocHGlobal(8192);
if (EnvironmentUtility.uname(buffer) == 0)
{
string os = Marshal.PtrToStringAnsi(buffer);
return os == "Darwin";
}
return false;
}
catch
{
return false; // default to Linux
}
finally
{
if (buffer != IntPtr.Zero)
Marshal.FreeHGlobal(buffer);
}
}
}
}

View File

@ -0,0 +1,15 @@
namespace StardewModdingAPI.Internal
{
/// <summary>The game's platform version.</summary>
internal enum Platform
{
/// <summary>The Linux version of the game.</summary>
Linux,
/// <summary>The Mac version of the game.</summary>
Mac,
/// <summary>The Windows version of the game.</summary>
Windows
}
}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<MSBuildAllProjects>$(MSBuildAllProjects);$(MSBuildThisFileFullPath)</MSBuildAllProjects>
<HasSharedItems>true</HasSharedItems>
<SharedGUID>85208f8d-6fd1-4531-be05-7142490f59fe</SharedGUID>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<Import_RootNamespace>SMAPI.Internal</Import_RootNamespace>
</PropertyGroup>
<ItemGroup>
<Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\ColorfulConsoleWriter.cs" />
<Compile Include="$(MSBuildThisFileDirectory)EnvironmentUtility.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\LogLevel.cs" />
<Compile Include="$(MSBuildThisFileDirectory)ConsoleWriting\MonitorColorScheme.cs" />
<Compile Include="$(MSBuildThisFileDirectory)Platform.cs" />
</ItemGroup>
</Project>

View File

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup Label="Globals">
<ProjectGuid>2aa02fb6-ff03-41cf-a215-2ee60ab4f5dc</ProjectGuid>
<ProjectGuid>85208f8d-6fd1-4531-be05-7142490f59fe</ProjectGuid>
<MinimumVisualStudioVersion>14.0</MinimumVisualStudioVersion>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.Default.props" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.Common.props" />
<PropertyGroup />
<Import Project="StardewModdingAPI.Common.projitems" Label="Shared" />
<Import Project="SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\CodeSharing\Microsoft.CodeSharing.CSharp.targets" />
</Project>

View File

@ -0,0 +1,10 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetCollection</c> for unit testing.</summary>
public class NetCollection<T> : Collection<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
}

View File

@ -0,0 +1,9 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
using System.Collections;
using System.Collections.Generic;
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary>
public class NetList<T> : List<T>, IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable { }
}

View File

@ -0,0 +1,6 @@
// ReSharper disable CheckNamespace -- matches Stardew Valley's code
namespace Netcode
{
/// <summary>A simplified version of Stardew Valley's <c>Netcode.NetObjectList</c> for unit testing.</summary>
public class NetObjectList<T> : NetList<T> { }
}

View File

@ -1,4 +1,5 @@
// ReSharper disable CheckNamespace, InconsistentNaming -- matches Stardew Valley's code
#pragma warning disable 649 // (never assigned) -- only used to test type conversions
using System.Collections.Generic;
namespace StardewValley
@ -6,6 +7,7 @@ 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;
/// <summary>A sample field which should be replaced with a different property.</summary>
public readonly IDictionary<string, int[]> friendships;
}
}

View File

@ -20,5 +20,14 @@ namespace StardewValley
/// <summary>A generic net ref property with no equivalent non-net property.</summary>
public NetRef<object> netRefProperty { get; } = new NetRef<object>();
/// <summary>A sample net list.</summary>
public readonly NetList<int> netList = new NetList<int>();
/// <summary>A sample net object list.</summary>
public readonly NetObjectList<int> netObjectList = new NetObjectList<int>();
/// <summary>A sample net collection.</summary>
public readonly NetCollection<int> netCollection = new NetCollection<int>();
}
}

View File

@ -59,7 +59,7 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
/// <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")] // ↓ implicit conversion
[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")]
@ -79,20 +79,24 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
[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.netIntField != 42);", 24, "obj.netIntField", "NetInt", "int")] // ↓ implicit conversion for parent field
[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")]
[TestCase("Item item = new Item(); object list = item.netList;", 38, "item.netList", "NetList", "object")] // ↓ NetList field converted to a non-interface type
[TestCase("Item item = new Item(); object list = item.netCollection;", 38, "item.netCollection", "NetCollection", "object")]
[TestCase("Item item = new Item(); int x = (int)item.netIntField;", 32, "item.netIntField", "NetInt", "int")] // ↓ explicit conversion to invalid type
[TestCase("Item item = new Item(); int x = item.netRefField as object;", 32, "item.netRefField", "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.",
Id = "AvoidImplicitNetFieldCast",
Message = $"This implicitly converts '{expression}' from {fromType} to {toType}, but {fromType} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/avoid-implicit-net-field-cast for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};
@ -101,6 +105,22 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
this.VerifyCSharpDiagnostic(code, expected);
}
/// <summary>Test that the net field analyzer doesn't raise any warnings for safe member access.</summary>
/// <param name="codeText">The code line to test.</param>
[TestCase("Item item = new Item(); System.Collections.IEnumerable list = farmer.eventsSeen;")]
[TestCase("Item item = new Item(); System.Collections.Generic.IEnumerable<int> list = farmer.netList;")]
[TestCase("Item item = new Item(); System.Collections.Generic.IList<int> list = farmer.netList;")]
[TestCase("Item item = new Item(); System.Collections.Generic.ICollection<int> list = farmer.netCollection;")]
[TestCase("Item item = new Item(); System.Collections.Generic.IList<int> list = farmer.netObjectList;")] // subclass of NetList
public void AvoidImplicitNetFieldComparisons_AllowsSafeAccess(string codeText)
{
// arrange
string code = NetFieldAnalyzerTests.SampleProgram.Replace("{{test-code}}", codeText);
// assert
this.VerifyCSharpDiagnostic(code);
}
/// <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>
@ -117,8 +137,8 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
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.",
Id = "AvoidNetField",
Message = $"'{expression}' is a {netType} field; consider using the {suggestedProperty} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", NetFieldAnalyzerTests.SampleCodeLine, NetFieldAnalyzerTests.SampleCodeColumn + column) }
};

View File

@ -59,14 +59,15 @@ namespace SMAPI.ModBuildConfig.Analyzer.Tests
/// <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")]
[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.",
Id = "AvoidObsoleteField",
Message = $"The '{oldName}' field is obsolete and should be replaced with '{newName}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.",
Severity = DiagnosticSeverity.Warning,
Locations = new[] { new DiagnosticResultLocation("Test0.cs", ObsoleteFieldAnalyzerTests.SampleCodeLine, ObsoleteFieldAnalyzerTests.SampleCodeColumn + column) }
};

View File

@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.7.2" />
<PackageReference Include="NUnit" Version="3.10.1" />
<PackageReference Include="NUnit3TestAdapter" Version="3.10.0" />
</ItemGroup>

View File

@ -0,0 +1,93 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace StardewModdingAPI.ModBuildConfig.Analyzer
{
/// <summary>Provides generic utilities for SMAPI's Roslyn analyzers.</summary>
internal static class AnalyzerUtilities
{
/*********
** Public methods
*********/
/// <summary>Get the metadata for an explicit cast or 'x as y' expression.</summary>
/// <param name="node">The member access expression.</param>
/// <param name="semanticModel">provides methods for asking semantic questions about syntax nodes.</param>
/// <param name="fromExpression">The expression whose value is being converted.</param>
/// <param name="fromType">The type being converted from.</param>
/// <param name="toType">The type being converted to.</param>
/// <returns>Returns true if the node is a matched expression, else false.</returns>
public static bool TryGetCastOrAsInfo(SyntaxNode node, SemanticModel semanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType)
{
// (type)x
if (node is CastExpressionSyntax cast)
{
fromExpression = cast.Expression;
fromType = semanticModel.GetTypeInfo(fromExpression);
toType = semanticModel.GetTypeInfo(cast.Type);
return true;
}
// x as y
if (node is BinaryExpressionSyntax binary && binary.Kind() == SyntaxKind.AsExpression)
{
fromExpression = binary.Left;
fromType = semanticModel.GetTypeInfo(fromExpression);
toType = semanticModel.GetTypeInfo(binary.Right);
return true;
}
// invalid
fromExpression = null;
fromType = default(TypeInfo);
toType = default(TypeInfo);
return false;
}
/// <summary>Get the metadata for a member access expression.</summary>
/// <param name="node">The member access expression.</param>
/// <param name="semanticModel">provides methods for asking semantic questions about syntax nodes.</param>
/// <param name="declaringType">The object type which has the member.</param>
/// <param name="memberType">The type of the accessed member.</param>
/// <param name="memberName">The name of the accessed member.</param>
/// <returns>Returns true if the node is a member access expression, else false.</returns>
public static bool TryGetMemberInfo(SyntaxNode node, SemanticModel semanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName)
{
// simple access
if (node is MemberAccessExpressionSyntax memberAccess)
{
declaringType = semanticModel.GetTypeInfo(memberAccess.Expression).Type;
memberType = semanticModel.GetTypeInfo(node);
memberName = memberAccess.Name.Identifier.Text;
return true;
}
// conditional access
if (node is ConditionalAccessExpressionSyntax conditionalAccess && conditionalAccess.WhenNotNull is MemberBindingExpressionSyntax conditionalBinding)
{
declaringType = semanticModel.GetTypeInfo(conditionalAccess.Expression).Type;
memberType = semanticModel.GetTypeInfo(node);
memberName = conditionalBinding.Name.Identifier.Text;
return true;
}
// invalid
declaringType = null;
memberType = default(TypeInfo);
memberName = null;
return false;
}
/// <summary>Get the class types in a type's inheritance chain, including itself.</summary>
/// <param name="type">The initial type.</param>
public static IEnumerable<ITypeSymbol> GetConcreteTypes(ITypeSymbol type)
{
while (type != null)
{
yield return type;
type = type.BaseType;
}
}
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
@ -21,6 +22,13 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Maps net fields to their equivalent non-net properties where available.</summary>
private readonly IDictionary<string, string> NetFieldWrapperProperties = new Dictionary<string, string>
{
// AnimatedSprite
["StardewValley.AnimatedSprite::currentAnimation"] = "CurrentAnimation",
["StardewValley.AnimatedSprite::currentFrame"] = "CurrentFrame",
["StardewValley.AnimatedSprite::sourceRect"] = "SourceRect",
["StardewValley.AnimatedSprite::spriteHeight"] = "SpriteHeight",
["StardewValley.AnimatedSprite::spriteWidth"] = "SpriteWidth",
// Character
["StardewValley.Character::currentLocationRef"] = "currentLocation",
["StardewValley.Character::facingDirection"] = "FacingDirection",
@ -106,7 +114,6 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
["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",
@ -124,28 +131,27 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
["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"
)
};
/// <summary>The diagnostic info for an implicit net field cast.</summary>
private readonly DiagnosticDescriptor AvoidImplicitNetFieldCastRule = new DiagnosticDescriptor(
id: "AvoidImplicitNetFieldCast",
title: "Netcode types shouldn't be implicitly converted",
messageFormat: "This implicitly converts '{0}' from {1} to {2}, but {1} has unintuitive implicit conversion rules. Consider comparing against the actual value instead to avoid bugs. See https://smapi.io/buildmsg/avoid-implicit-net-field-cast for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/avoid-implicit-net-field-cast"
);
/// <summary>The diagnostic info for an avoidable net field access.</summary>
private readonly DiagnosticDescriptor AvoidNetFieldRule = new DiagnosticDescriptor(
id: "AvoidNetField",
title: "Avoid Netcode types when possible",
messageFormat: "'{0}' is a {1} field; consider using the {2} property instead. See https://smapi.io/buildmsg/avoid-net-field for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/avoid-net-field"
);
/*********
@ -161,22 +167,25 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <summary>Construct an instance.</summary>
public NetFieldAnalyzer()
{
this.SupportedDiagnostics = ImmutableArray.CreateRange(this.Rules.Values);
this.SupportedDiagnostics = ImmutableArray.CreateRange(new[] { this.AvoidNetFieldRule, this.AvoidImplicitNetFieldCastRule });
}
/// <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
this.AnalyzeMemberAccess,
SyntaxKind.SimpleMemberAccessExpression,
SyntaxKind.ConditionalAccessExpression
);
// SMAPI001: avoid implicit net field conversion
context.RegisterSyntaxNodeAction(
this.AnalyseNetFieldConversions,
this.AnalyzeCast,
SyntaxKind.CastExpression,
SyntaxKind.AsExpression
);
context.RegisterSyntaxNodeAction(
this.AnalyzeBinaryComparison,
SyntaxKind.EqualsExpression,
SyntaxKind.NotEqualsExpression,
SyntaxKind.GreaterThanExpression,
@ -190,77 +199,126 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/*********
** 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>
/// <summary>Analyse a member access syntax node and add a diagnostic message if applicable.</summary>
/// <param name="context">The analysis context.</param>
private void AnalyzeAvoidableNetField(SyntaxNodeAnalysisContext context)
/// <returns>Returns whether any warnings were added.</returns>
private void AnalyzeMemberAccess(SyntaxNodeAnalysisContext context)
{
try
this.HandleErrors(context.Node, () =>
{
// check member type
MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
TypeInfo memberType = context.SemanticModel.GetTypeInfo(node);
// get member access info
if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName))
return;
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)
// warn: use property wrapper if available
foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType))
{
if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{propertyName}", out string suggestedPropertyName))
if (this.NetFieldWrapperProperties.TryGetValue($"{type}::{memberName}", out string suggestedPropertyName))
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI002"], context.Node.GetLocation(), node, memberType.Type.Name, suggestedPropertyName));
break;
context.ReportDiagnostic(Diagnostic.Create(this.AvoidNetFieldRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, suggestedPropertyName));
return;
}
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
// warn: implicit conversion
if (this.IsInvalidConversion(memberType.Type, memberType.ConvertedType))
{
context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), context.Node, memberType.Type.Name, memberType.ConvertedType));
return;
}
});
}
/// <summary>Analyse a syntax node and add a diagnostic message if it implicitly converts a net field.</summary>
/// <summary>Analyse an explicit cast or 'x as y' node and add a diagnostic message if applicable.</summary>
/// <param name="context">The analysis context.</param>
private void AnalyseNetFieldConversions(SyntaxNodeAnalysisContext context)
/// <returns>Returns whether any warnings were added.</returns>
private void AnalyzeCast(SyntaxNodeAnalysisContext context)
{
try
// NOTE: implicit conversion within the expression is detected by the member access
// checks. This method is only concerned with the conversion of its final value.
this.HandleErrors(context.Node, () =>
{
BinaryExpressionSyntax binaryExpression = (BinaryExpressionSyntax)context.Node;
foreach (var pair in new[] { Tuple.Create(binaryExpression.Left, binaryExpression.Right), Tuple.Create(binaryExpression.Right, binaryExpression.Left) })
if (AnalyzerUtilities.TryGetCastOrAsInfo(context.Node, context.SemanticModel, out ExpressionSyntax fromExpression, out TypeInfo fromType, out TypeInfo toType))
{
if (this.IsInvalidConversion(fromType.ConvertedType, toType.Type))
context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), fromExpression, fromType.ConvertedType.Name, toType.Type));
}
});
}
/// <summary>Analyse a binary comparison syntax node and add a diagnostic message if applicable.</summary>
/// <param name="context">The analysis context.</param>
/// <returns>Returns whether any warnings were added.</returns>
private void AnalyzeBinaryComparison(SyntaxNodeAnalysisContext context)
{
// NOTE: implicit conversion within an operand is detected by the member access checks.
// This method is only concerned with the conversion of each side's final value.
this.HandleErrors(context.Node, () =>
{
BinaryExpressionSyntax expression = (BinaryExpressionSyntax)context.Node;
foreach (var pair in new[] { Tuple.Create(expression.Left, expression.Right), Tuple.Create(expression.Right, expression.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))
TypeInfo curType = context.SemanticModel.GetTypeInfo(curExpression);
TypeInfo otherType = context.SemanticModel.GetTypeInfo(otherExpression);
if (!this.IsNetType(curType.ConvertedType))
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"));
context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, "null"));
break;
}
// warn for implicit conversion
if (!this.IsNetType(otherType.ConvertedType))
{
context.ReportDiagnostic(Diagnostic.Create(this.AvoidImplicitNetFieldCastRule, context.Node.GetLocation(), curExpression, curType.Type.Name, curType.ConvertedType));
break;
}
}
});
}
/// <summary>Handle exceptions raised while analyzing a node.</summary>
/// <param name="node">The node being analysed.</param>
/// <param name="action">The callback to invoke.</param>
private void HandleErrors(SyntaxNode node, Action action)
{
try
{
action();
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed processing expression: '{context.Node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
throw new InvalidOperationException($"Failed processing expression: '{node}'. Exception details: {ex.ToString().Replace('\r', ' ').Replace('\n', ' ')}");
}
}
/// <summary>Get whether a net field was converted in an error-prone way.</summary>
/// <param name="fromType">The source type.</param>
/// <param name="toType">The target type.</param>
private bool IsInvalidConversion(ITypeSymbol fromType, ITypeSymbol toType)
{
// no conversion
if (!this.IsNetType(fromType) || this.IsNetType(toType))
return false;
// conversion to implemented interface is OK
if (fromType.AllInterfaces.Contains(toType))
return false;
// avoid any other conversions
return true;
}
/// <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)

View File

@ -3,7 +3,6 @@ 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
@ -25,14 +24,14 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <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",
["AvoidObsoleteField"] = new DiagnosticDescriptor(
id: "AvoidObsoleteField",
title: "Reference to obsolete field",
messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/smapi003 for details.",
messageFormat: "The '{0}' field is obsolete and should be replaced with '{1}'. See https://smapi.io/buildmsg/avoid-obsolete-field for details.",
category: "SMAPI.CommonErrors",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true,
helpLinkUri: "https://smapi.io/buildmsg/smapi003"
helpLinkUri: "https://smapi.io/buildmsg/avoid-obsolete-field"
)
};
@ -57,10 +56,10 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
/// <param name="context">The analysis context.</param>
public override void Initialize(AnalysisContext context)
{
// SMAPI003: avoid obsolete fields
context.RegisterSyntaxNodeAction(
this.AnalyzeObsoleteFields,
SyntaxKind.SimpleMemberAccessExpression
SyntaxKind.SimpleMemberAccessExpression,
SyntaxKind.ConditionalAccessExpression
);
}
@ -75,16 +74,15 @@ namespace StardewModdingAPI.ModBuildConfig.Analyzer
try
{
// get reference info
MemberAccessExpressionSyntax node = (MemberAccessExpressionSyntax)context.Node;
ITypeSymbol declaringType = context.SemanticModel.GetTypeInfo(node.Expression).Type;
string propertyName = node.Name.Identifier.Text;
if (!AnalyzerUtilities.TryGetMemberInfo(context.Node, context.SemanticModel, out ITypeSymbol declaringType, out TypeInfo memberType, out string memberName))
return;
// suggest replacement
for (ITypeSymbol type = declaringType; type != null; type = type.BaseType)
foreach (ITypeSymbol type in AnalyzerUtilities.GetConcreteTypes(declaringType))
{
if (this.ReplacedFields.TryGetValue($"{type}::{propertyName}", out string replacement))
if (this.ReplacedFields.TryGetValue($"{type}::{memberName}", out string replacement))
{
context.ReportDiagnostic(Diagnostic.Create(this.Rules["SMAPI003"], context.Node.GetLocation(), $"{type}.{propertyName}", replacement));
context.ReportDiagnostic(Diagnostic.Create(this.Rules["AvoidObsoleteField"], context.Node.GetLocation(), $"{type}.{memberName}", replacement));
break;
}
}

View File

@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.4.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.8.2" PrivateAssets="all" />
<PackageReference Update="NETStandard.Library" PrivateAssets="all" />
</ItemGroup>

View File

@ -2,6 +2,8 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.RegularExpressions;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using StardewModdingAPI.ModBuildConfig.Framework;
@ -42,6 +44,9 @@ namespace StardewModdingAPI.ModBuildConfig
[Required]
public bool EnableModZip { get; set; }
/// <summary>Custom comma-separated regex patterns matching files to ignore when deploying or zipping the mod.</summary>
public string IgnoreModFilePatterns { get; set; }
/*********
** Public methods
@ -55,8 +60,11 @@ namespace StardewModdingAPI.ModBuildConfig
try
{
// parse ignore patterns
Regex[] ignoreFilePatterns = this.GetCustomIgnorePatterns().ToArray();
// get mod info
ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir);
ModFileManager package = new ModFileManager(this.ProjectDir, this.TargetDir, ignoreFilePatterns, validateRequiredModFiles: this.EnableModDeploy || this.EnableModZip);
// deploy mod files
if (this.EnableModDeploy)
@ -91,6 +99,29 @@ namespace StardewModdingAPI.ModBuildConfig
/*********
** Private methods
*********/
/// <summary>Get the custom ignore patterns provided by the user.</summary>
private IEnumerable<Regex> GetCustomIgnorePatterns()
{
if (string.IsNullOrWhiteSpace(this.IgnoreModFilePatterns))
yield break;
foreach (string raw in this.IgnoreModFilePatterns.Split(','))
{
Regex regex;
try
{
regex = new Regex(raw.Trim(), RegexOptions.IgnoreCase);
}
catch (Exception ex)
{
this.Log.LogWarning($"Ignored invalid <{nameof(this.IgnoreModFilePatterns)}> pattern {raw}:\n{ex}");
continue;
}
yield return regex;
}
}
/// <summary>Copy the mod files into the game's mod folder.</summary>
/// <param name="files">The files to include.</param>
/// <param name="modFolderPath">The folder path to create with the mod files.</param>

View File

@ -2,8 +2,9 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;
using StardewModdingAPI.Common;
using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.ModBuildConfig.Framework
{
@ -26,8 +27,10 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
/// <summary>Construct an instance.</summary>
/// <param name="projectDir">The folder containing the project files.</param>
/// <param name="targetDir">The folder containing the build output.</param>
/// <param name="ignoreFilePatterns">Custom regex patterns matching files to ignore when deploying or zipping the mod.</param>
/// <param name="validateRequiredModFiles">Whether to validate that required mod files like the manifest are present.</param>
/// <exception cref="UserErrorException">The mod package isn't valid.</exception>
public ModFileManager(string projectDir, string targetDir)
public ModFileManager(string projectDir, string targetDir, Regex[] ignoreFilePatterns, bool validateRequiredModFiles)
{
this.Files = new Dictionary<string, FileInfo>(StringComparer.InvariantCultureIgnoreCase);
@ -72,26 +75,26 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
if (hasProjectTranslations && this.EqualsInvariant(relativeDirPath, "i18n"))
continue;
// ignore release zips
if (this.EqualsInvariant(file.Extension, ".zip"))
continue;
// ignore Json.NET (bundled into SMAPI)
if (this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll") || this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml"))
// handle ignored files
if (this.ShouldIgnore(file, relativePath, ignoreFilePatterns))
continue;
// add file
this.Files[relativePath] = file;
}
// check for missing manifest
if (!this.Files.ContainsKey(this.ManifestFileName))
throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output.");
// check for required files
if (validateRequiredModFiles)
{
// manifest
if (!this.Files.ContainsKey(this.ManifestFileName))
throw new UserErrorException($"Could not create mod package because no {this.ManifestFileName} was found in the project or build output.");
// check for missing DLL
// ReSharper disable once SimplifyLinqExpression
if (!this.Files.Any(p => !p.Key.EndsWith(".dll")))
throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output.");
// DLL
// ReSharper disable once SimplifyLinqExpression
if (!this.Files.Any(p => !p.Key.EndsWith(".dll")))
throw new UserErrorException("Could not create mod package because no .dll file was found in the project or build output.");
}
}
/// <summary>Get the files in the mod package.</summary>
@ -136,15 +139,41 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
int minor = versionFields.ContainsKey("MinorVersion") ? (int)versionFields["MinorVersion"] : 0;
int patch = versionFields.ContainsKey("PatchVersion") ? (int)versionFields["PatchVersion"] : 0;
string tag = versionFields.ContainsKey("Build") ? (string)versionFields["Build"] : null;
return new SemanticVersionImpl(major, minor, patch, tag).ToString();
return new SemanticVersion(major, minor, patch, tag).ToString();
}
return new SemanticVersionImpl(versionObj.ToString()).ToString(); // SMAPI 2.0+
return new SemanticVersion(versionObj.ToString()).ToString(); // SMAPI 2.0+
}
/*********
** Private methods
*********/
/// <summary>Get whether a build output file should be ignored.</summary>
/// <param name="file">The file to check.</param>
/// <param name="relativePath">The file's relative path in the package.</param>
/// <param name="ignoreFilePatterns">Custom regex patterns matching files to ignore when deploying or zipping the mod.</param>
private bool ShouldIgnore(FileInfo file, string relativePath, Regex[] ignoreFilePatterns)
{
return
// release zips
this.EqualsInvariant(file.Extension, ".zip")
// Json.NET (bundled into SMAPI)
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll")
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.xml")
// code analysis files
|| file.Name.EndsWith(".CodeAnalysisLog.xml", StringComparison.InvariantCultureIgnoreCase)
|| file.Name.EndsWith(".lastcodeanalysissucceeded", StringComparison.InvariantCultureIgnoreCase)
// OS metadata files
|| this.EqualsInvariant(file.Name, ".DS_Store")
|| this.EqualsInvariant(file.Name, "Thumbs.db")
// custom ignore patterns
|| ignoreFilePatterns.Any(p => p.IsMatch(relativePath));
}
/// <summary>Get a case-insensitive dictionary matching the given JSON.</summary>
/// <param name="json">The JSON to parse.</param>
private IDictionary<string, object> Parse(string json)

View File

@ -2,5 +2,5 @@ using System.Reflection;
[assembly: AssemblyTitle("SMAPI.ModBuildConfig")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyVersion("2.0.2.0")]
[assembly: AssemblyFileVersion("2.0.2.0")]
[assembly: AssemblyVersion("2.1.0.0")]
[assembly: AssemblyFileVersion("2.1.0.0")]

View File

@ -55,7 +55,14 @@
<ItemGroup>
<Content Include="assets\nuget-icon.png" />
</ItemGroup>
<Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<ItemGroup>
<ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj">
<Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project>
<Name>StardewModdingAPI.Toolkit</Name>
</ProjectReference>
</ItemGroup>
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />
<Import Project="..\..\build\prepare-nuget-package.targets" />
</Project>

View File

@ -19,14 +19,10 @@
<!-- set default settings -->
<ModFolderName Condition="'$(ModFolderName)' == ''">$(MSBuildProjectName)</ModFolderName>
<ModUnitTests Condition="'$(ModUnitTests)' == ''">False</ModUnitTests>
<ModZipPath Condition="'$(ModZipPath)' == ''">$(TargetDir)</ModZipPath>
<EnableModDeploy Condition="'$(EnableModDeploy)' == ''">True</EnableModDeploy>
<EnableModZip Condition="'$(EnableModZip)' == ''">True</EnableModZip>
<!-- disable mod deploy in unit test project -->
<EnableModDeploy Condition="'$(ModUnitTests)' == true">False</EnableModDeploy>
<EnableModZip Condition="'$(ModUnitTests)' == true">False</EnableModZip>
<CopyModReferencesToBuildOutput Condition="'$(CopyModReferencesToBuildOutput)' == ''">False</CopyModReferencesToBuildOutput>
</PropertyGroup>
<!-- find platform + game path -->
@ -35,6 +31,7 @@
<PropertyGroup>
<!-- Linux -->
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/GOG Games/Stardew Valley/game</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.steam/steam/steamapps/common/Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">$(HOME)/.local/share/Steam/steamapps/common/Stardew Valley</GamePath>
<!-- Mac (may be 'Unix' or 'OSX') -->
@ -44,10 +41,18 @@
</When>
<When Condition="$(OS) == 'Windows_NT'">
<PropertyGroup>
<!-- default paths -->
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GalaxyClient\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\GOG Galaxy\Games\Stardew Valley</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">C:\Program Files (x86)\Steam\steamapps\common\Stardew Valley</GamePath>
<!-- registry paths -->
<GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\GOG.com\Games\1453375253', 'PATH', null, RegistryView.Registry32))</GamePath>
<GamePath Condition="!Exists('$(GamePath)')">$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Steam App 413150', 'InstallLocation', null, RegistryView.Registry64, RegistryView.Registry32))</GamePath>
<!-- derive from Steam library path -->
<_SteamLibraryPath>$([MSBuild]::GetRegistryValueFromView('HKEY_CURRENT_USER\SOFTWARE\Valve\Steam', 'SteamPath', null, RegistryView.Registry32))</_SteamLibraryPath>
<GamePath Condition="!Exists('$(GamePath)') AND '$(_SteamLibraryPath)' != ''">$(_SteamLibraryPath)\steamapps\common\Stardew Valley</GamePath>
</PropertyGroup>
</When>
</Choose>
@ -62,40 +67,45 @@
<ItemGroup>
<Reference Include="Microsoft.Xna.Framework, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Game, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Graphics, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Microsoft.Xna.Framework.Xact, Version=4.0.0.0, Culture=neutral, PublicKeyToken=842cf8be1de50553, processorArchitecture=x86">
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Netcode" Condition="Exists('$(GamePath)\Netcode.dll')">
<Reference Include="Netcode">
<HintPath>$(GamePath)\Netcode.dll</HintPath>
<Private>False</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="Stardew Valley">
<HintPath>$(GamePath)\Stardew Valley.exe</HintPath>
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI">
<HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath>
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces">
<HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath>
<Private>false</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="xTile, Version=2.0.4.0, Culture=neutral, processorArchitecture=x86">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>false</Private>
<SpecificVersion>False</SpecificVersion>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
</ItemGroup>
@ -113,22 +123,27 @@
<HintPath>$(GamePath)\MonoGame.Framework.dll</HintPath>
<Private>false</Private>
<SpecificVersion>False</SpecificVersion>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewValley">
<HintPath>$(GamePath)\StardewValley.exe</HintPath>
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI">
<HintPath>$(GamePath)\StardewModdingAPI.exe</HintPath>
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="StardewModdingAPI.Toolkit.CoreInterfaces">
<HintPath>$(GamePath)\StardewModdingAPI.Toolkit.CoreInterfaces.dll</HintPath>
<Private>false</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
<Reference Include="xTile">
<HintPath>$(GamePath)\xTile.dll</HintPath>
<Private>false</Private>
<Private Condition="$(ModUnitTests)">true</Private>
<Private Condition="$(CopyModReferencesToBuildOutput)">true</Private>
</Reference>
</ItemGroup>
</Otherwise>
@ -160,6 +175,7 @@
ProjectDir="$(ProjectDir)"
TargetDir="$(TargetDir)"
GameDir="$(GamePath)"
IgnoreModFilePatterns="$(IgnoreModFilePatterns)"
/>
</Target>
</Project>

View File

@ -2,7 +2,7 @@
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
<metadata>
<id>Pathoschild.Stardew.ModBuildConfig</id>
<version>2.1-alpha20180410</version>
<version>2.1.0</version>
<title>Build package for SMAPI mods</title>
<authors>Pathoschild</authors>
<owners>Pathoschild</owners>
@ -10,12 +10,15 @@
<licenseUrl>https://github.com/Pathoschild/SMAPI/blob/develop/LICENSE.txt</licenseUrl>
<projectUrl>https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#readme</projectUrl>
<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>
<description>Automates the build configuration for crossplatform Stardew Valley SMAPI mods. For Stardew Valley 1.3 or later.</description>
<releaseNotes>
2.1:
- Added support for Stardew Valley 1.3.
- Added support for unit test projects.
- Added support for non-mod projects.
- Added C# analyzers to warn about implicit conversions of Netcode fields in Stardew Valley 1.3.
- Added option to ignore files by regex pattern.
- Added reference to new SMAPI DLL.
- Fixed some game paths not detected by NuGet package.
</releaseNotes>
</metadata>
</package>

View File

@ -15,14 +15,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <summary>Provides methods for searching and constructing items.</summary>
private readonly ItemRepository Items = new ItemRepository();
/// <summary>The type names recognised by this command.</summary>
private readonly string[] ValidTypes = Enum.GetNames(typeof(ItemType)).Concat(new[] { "Name" }).ToArray();
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public AddCommand()
: base("player_add", AddCommand.GetDescription())
{ }
: base("player_add", AddCommand.GetDescription()) { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
@ -30,35 +32,34 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// read arguments
if (!args.TryGet(0, "item type", out string rawType, oneOf: Enum.GetNames(typeof(ItemType))))
// validate
if (!Context.IsWorldReady)
{
monitor.Log("You need to load a save to use this command.", LogLevel.Error);
return;
if (!args.TryGetInt(1, "item ID", out int id, min: 0))
}
// read arguments
if (!args.TryGet(0, "item type", out string type, oneOf: this.ValidTypes))
return;
if (!args.TryGetInt(2, "count", out int count, min: 1, required: false))
count = 1;
if (!args.TryGetInt(3, "quality", out int quality, min: Object.lowQuality, max: Object.bestQuality, required: false))
quality = Object.lowQuality;
ItemType type = (ItemType)Enum.Parse(typeof(ItemType), rawType, ignoreCase: true);
// find matching item
SearchableItem match = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
SearchableItem match = Enum.TryParse(type, true, out ItemType itemType)
? this.FindItemByID(monitor, args, itemType)
: this.FindItemByName(monitor, args);
if (match == null)
{
monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error);
return;
}
// apply count
match.Item.Stack = count;
// apply quality
if (match.Item is Object obj)
#if STARDEW_VALLEY_1_3
obj.Quality = quality;
#else
obj.quality = quality;
#endif
else if (match.Item is Tool tool)
tool.UpgradeLevel = quality;
@ -67,9 +68,60 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
monitor.Log($"OK, added {match.Name} ({match.Type} #{match.ID}) to your inventory.", LogLevel.Info);
}
/*********
** Private methods
*********/
/// <summary>Get a matching item by its ID.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
/// <param name="type">The item type.</param>
private SearchableItem FindItemByID(IMonitor monitor, ArgumentParser args, ItemType type)
{
// read arguments
if (!args.TryGetInt(1, "item ID", out int id, min: 0))
return null;
// find matching item
SearchableItem item = this.Items.GetAll().FirstOrDefault(p => p.Type == type && p.ID == id);
if (item == null)
monitor.Log($"There's no {type} item with ID {id}.", LogLevel.Error);
return item;
}
/// <summary>Get a matching item by its name.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="args">The command arguments.</param>
private SearchableItem FindItemByName(IMonitor monitor, ArgumentParser args)
{
// read arguments
if (!args.TryGet(1, "item name", out string name))
return null;
// find matching items
SearchableItem[] matches = this.Items.GetAll().Where(p => p.NameContains(name)).ToArray();
if (!matches.Any())
{
monitor.Log($"There's no item with name '{name}'. You can use the 'list_items [name]' command to search for items.", LogLevel.Error);
return null;
}
// handle single exact match
SearchableItem[] exactMatches = matches.Where(p => p.NameEquivalentTo(name)).ToArray();
if (exactMatches.Length == 1)
return exactMatches[0];
// handle ambiguous results
string options = this.GetTableString(
data: matches,
header: new[] { "type", "name", "command" },
getRow: item => new[] { item.Type.ToString(), item.DisplayName, $"player_add {item.Type} {item.ID}" }
);
monitor.Log($"There's no item with name '{name}'. Do you mean one of these?\n\n{options}", LogLevel.Info);
return null;
}
/// <summary>Get the command description.</summary>
private static string GetDescription()
{
string[] typeValues = Enum.GetNames(typeof(ItemType));
@ -81,8 +133,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
+ "- count (optional): how many of the item to give.\n"
+ $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n"
+ "\n"
+ "This example adds the galaxy sword to your inventory:\n"
+ " player_add weapon 4";
+ "Usage: player_add name \"<name>\" [count] [quality]\n"
+ "- name: the item name to search (use the 'list_items' command to see a list). This will add the item immediately if it's an exact match, else show a table of matching items.\n"
+ "- count (optional): how many of the item to give.\n"
+ $"- quality (optional): one of {Object.lowQuality} (normal), {Object.medQuality} (silver), {Object.highQuality} (gold), or {Object.bestQuality} (iridium).\n"
+ "\n"
+ "These examples both add the galaxy sword to your inventory:\n"
+ " player_add weapon 4\n"
+ " player_add name \"Galaxy Sword\"";
}
}
}

View File

@ -36,11 +36,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
switch (target)
{
case "hair":
#if STARDEW_VALLEY_1_3
Game1.player.hairstyleColor.Value = color;
#else
Game1.player.hairstyleColor = color;
#endif
monitor.Log("OK, your hair color is updated.", LogLevel.Info);
break;
@ -50,11 +46,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
break;
case "pants":
#if STARDEW_VALLEY_1_3
Game1.player.pantsColor.Value = color;
#else
Game1.player.pantsColor = color;
#endif
monitor.Log("OK, your pants color is updated.", LogLevel.Info);
break;
}

View File

@ -1,91 +0,0 @@
using System.Collections.Generic;
using StardewValley;
using SFarmer = StardewValley.Farmer;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current level for a skill.</summary>
internal class SetLevelCommand : TrainerCommand
{
/*********
** Properties
*********/
/// <summary>The experience points needed to reach each level.</summary>
/// <remarks>Derived from <see cref="SFarmer.checkForLevelGain"/>.</remarks>
private readonly IDictionary<int, int> LevelExp = new Dictionary<int, int>
{
[0] = 0,
[1] = 100,
[2] = 380,
[3] = 770,
[4] = 1300,
[5] = 2150,
[6] = 3300,
[7] = 4800,
[8] = 6900,
[9] = 10000,
[10] = 15000
};
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public SetLevelCommand()
: base("player_setlevel", "Sets the player's specified skill to the specified value.\n\nUsage: player_setlevel <skill> <value>\n- skill: the skill to set (one of 'luck', 'mining', 'combat', 'farming', 'fishing', or 'foraging').\n- value: the target level (a number from 1 to 10).") { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="command">The command name.</param>
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// validate
if (!args.TryGet(0, "skill", out string skill, oneOf: new[] { "luck", "mining", "combat", "farming", "fishing", "foraging" }))
return;
if (!args.TryGetInt(1, "level", out int level, min: 0, max: 10))
return;
// handle
switch (skill)
{
case "luck":
Game1.player.LuckLevel = level;
Game1.player.experiencePoints[SFarmer.luckSkill] = this.LevelExp[level];
monitor.Log($"OK, your luck skill is now {Game1.player.LuckLevel}.", LogLevel.Info);
break;
case "mining":
Game1.player.MiningLevel = level;
Game1.player.experiencePoints[SFarmer.miningSkill] = this.LevelExp[level];
monitor.Log($"OK, your mining skill is now {Game1.player.MiningLevel}.", LogLevel.Info);
break;
case "combat":
Game1.player.CombatLevel = level;
Game1.player.experiencePoints[SFarmer.combatSkill] = this.LevelExp[level];
monitor.Log($"OK, your combat skill is now {Game1.player.CombatLevel}.", LogLevel.Info);
break;
case "farming":
Game1.player.FarmingLevel = level;
Game1.player.experiencePoints[SFarmer.farmingSkill] = this.LevelExp[level];
monitor.Log($"OK, your farming skill is now {Game1.player.FarmingLevel}.", LogLevel.Info);
break;
case "fishing":
Game1.player.FishingLevel = level;
Game1.player.experiencePoints[SFarmer.fishingSkill] = this.LevelExp[level];
monitor.Log($"OK, your fishing skill is now {Game1.player.FishingLevel}.", LogLevel.Info);
break;
case "foraging":
Game1.player.ForagingLevel = level;
Game1.player.experiencePoints[SFarmer.foragingSkill] = this.LevelExp[level];
monitor.Log($"OK, your foraging skill is now {Game1.player.ForagingLevel}.", LogLevel.Info);
break;
}
}
}
}

View File

@ -39,11 +39,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
case "farm":
if (!string.IsNullOrWhiteSpace(name))
{
#if STARDEW_VALLEY_1_3
Game1.player.farmName.Value = args[1];
#else
Game1.player.farmName = args[1];
#endif
monitor.Log($"OK, your farm's name is now {Game1.player.farmName}.", LogLevel.Info);
}
else

View File

@ -1,30 +0,0 @@
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
/// <summary>A command which edits the player's current added speed.</summary>
internal class SetSpeedCommand : TrainerCommand
{
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
public SetSpeedCommand()
: base("player_setspeed", "Sets the player's added speed to the specified value.\n\nUsage: player_setspeed <value>\n- value: an integer amount (0 is normal).") { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>
/// <param name="command">The command name.</param>
/// <param name="args">The command arguments.</param>
public override void Handle(IMonitor monitor, string command, ArgumentParser args)
{
// parse arguments
if (!args.TryGetInt(0, "added speed", out int amount, min: 0))
return;
// handle
Game1.player.addedSpeed = amount;
monitor.Log($"OK, your added speed is now {Game1.player.addedSpeed}.", LogLevel.Info);
}
}
}

View File

@ -1,4 +1,4 @@
using StardewValley;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
{
@ -10,7 +10,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
*********/
/// <summary>Construct an instance.</summary>
public SetStyleCommand()
: base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changecolor <target> <value>.\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { }
: base("player_changestyle", "Sets the style of a player feature.\n\nUsage: player_changestyle <target> <value>.\n- target: what to change (one of 'hair', 'shirt', 'skin', 'acc', 'shoe', 'swim', or 'gender').\n- value: the integer style ID.") { }
/// <summary>Handle the command.</summary>
/// <param name="monitor">Writes messages to the console and log file.</param>

View File

@ -21,11 +21,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
{
int level = (Game1.currentLocation as MineShaft)?.mineLevel ?? 0;
monitor.Log($"OK, warping you to mine level {level + 1}.", LogLevel.Info);
#if STARDEW_VALLEY_1_3
Game1.enterMine(level + 1);
#else
Game1.enterMine(false, level + 1, "");
#endif
}
}
}

View File

@ -26,11 +26,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
// handle
level = Math.Max(1, level);
monitor.Log($"OK, warping you to mine level {level}.", LogLevel.Info);
#if STARDEW_VALLEY_1_3
Game1.enterMine(level);
#else
Game1.enterMine(true, level, "");
#endif
}
}
}

View File

@ -1,4 +1,4 @@
using System.Linq;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@ -38,7 +38,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
return;
// handle
Game1.currentSeason = season;
Game1.currentSeason = season.ToLower();
Game1.setGraphicsForSeason();
monitor.Log($"OK, the date is now {Game1.currentSeason} {Game1.dayOfMonth}.", LogLevel.Info);
}
}

View File

@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
@ -31,9 +32,38 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
return;
// handle
Game1.timeOfDay = time;
this.SafelySetTime(time);
FreezeTimeCommand.FrozenTime = Game1.timeOfDay;
monitor.Log($"OK, the time is now {Game1.timeOfDay.ToString().PadLeft(4, '0')}.", LogLevel.Info);
}
/*********
** Private methods
*********/
/// <summary>Safely transition to the given time, allowing NPCs to update their schedule.</summary>
/// <param name="time">The time of day.</param>
private void SafelySetTime(int time)
{
// define conversion between game time and TimeSpan
TimeSpan ToTimeSpan(int value) => new TimeSpan(0, value / 100, value % 100, 0);
int FromTimeSpan(TimeSpan span) => (int)((span.Hours * 100) + span.Minutes);
// transition to new time
int intervals = (int)((ToTimeSpan(time) - ToTimeSpan(Game1.timeOfDay)).TotalMinutes / 10);
if (intervals > 0)
{
for (int i = 0; i < intervals; i++)
Game1.performTenMinuteClockUpdate();
}
else if (intervals < 0)
{
for (int i = 0; i > intervals; i--)
{
Game1.timeOfDay = FromTimeSpan(ToTimeSpan(Game1.timeOfDay).Subtract(TimeSpan.FromMinutes(20))); // offset 20 mins so game updates to next interval
Game1.performTenMinuteClockUpdate();
}
}
}
}
}

View File

@ -1,4 +1,5 @@
using StardewValley;
using System;
using StardewValley;
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData
{
@ -37,5 +38,23 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.ItemData
this.ID = id;
this.Item = item;
}
/// <summary>Get whether the item name contains a case-insensitive substring.</summary>
/// <param name="substring">The substring to find.</param>
public bool NameContains(string substring)
{
return
this.Name.IndexOf(substring, StringComparison.InvariantCultureIgnoreCase) != -1
|| this.DisplayName.IndexOf(substring, StringComparison.InvariantCultureIgnoreCase) != -1;
}
/// <summary>Get whether the item name is exactly equal to a case-insensitive string.</summary>
/// <param name="name">The substring to find.</param>
public bool NameEquivalentTo(string name)
{
return
this.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)
|| this.DisplayName.Equals(name, StringComparison.InvariantCultureIgnoreCase);
}
}
}

View File

@ -83,9 +83,19 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
foreach (int id in Game1.bigCraftablesInformation.Keys)
yield return new SearchableItem(ItemType.BigCraftable, id, new SObject(Vector2.Zero, id));
// secret notes
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
{
SObject note = new SObject(79, 1);
note.name = $"{note.name} #{id}";
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, note);
}
// objects
foreach (int id in Game1.objectInformation.Keys)
{
if (id == 79)
continue; // secret note handled above
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
continue; // handled separated
@ -96,28 +106,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
if (item.category == SObject.FruitsCategory)
{
// wine
#if STARDEW_VALLEY_1_3
SObject wine =
new SObject(348, 1)
SObject wine = new SObject(348, 1)
{
Name = $"{item.Name} Wine",
Price = item.price * 3
};
wine.preserve.Value = SObject.PreserveType.Wine;
wine.preservedParentSheetIndex.Value = item.parentSheetIndex;
#else
SObject wine = new SObject(348, 1)
{
name = $"{item.Name} Wine",
price = item.price * 3,
preserve = SObject.PreserveType.Wine,
preservedParentSheetIndex = item.parentSheetIndex
};
#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset + id, wine);
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, wine);
// jelly
#if STARDEW_VALLEY_1_3
SObject jelly = new SObject(344, 1)
{
Name = $"{item.Name} Jelly",
@ -125,23 +123,13 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
jelly.preserve.Value = SObject.PreserveType.Jelly;
jelly.preservedParentSheetIndex.Value = item.parentSheetIndex;
#else
SObject jelly = new SObject(344, 1)
{
name = $"{item.Name} Jelly",
price = 50 + item.Price * 2,
preserve = SObject.PreserveType.Jelly,
preservedParentSheetIndex = item.parentSheetIndex
};
#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 2 + id, jelly);
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, jelly);
}
// vegetable products
else if (item.category == SObject.VegetableCategory)
{
// juice
#if STARDEW_VALLEY_1_3
SObject juice = new SObject(350, 1)
{
Name = $"{item.Name} Juice",
@ -149,19 +137,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
juice.preserve.Value = SObject.PreserveType.Juice;
juice.preservedParentSheetIndex.Value = item.parentSheetIndex;
#else
SObject juice = new SObject(350, 1)
{
name = $"{item.Name} Juice",
price = (int)(item.price * 2.25d),
preserve = SObject.PreserveType.Juice,
preservedParentSheetIndex = item.parentSheetIndex
};
#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 3 + id, juice);
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, juice);
// pickled
#if STARDEW_VALLEY_1_3
SObject pickled = new SObject(342, 1)
{
Name = $"Pickled {item.Name}",
@ -169,16 +147,7 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
};
pickled.preserve.Value = SObject.PreserveType.Pickle;
pickled.preservedParentSheetIndex.Value = item.parentSheetIndex;
#else
SObject pickled = new SObject(342, 1)
{
name = $"Pickled {item.Name}",
price = 50 + item.Price * 2,
preserve = SObject.PreserveType.Pickle,
preservedParentSheetIndex = item.parentSheetIndex
};
#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 4 + id, pickled);
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, pickled);
}
// flower honey
@ -211,7 +180,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
// yield honey
if (type != null)
{
#if STARDEW_VALLEY_1_3
SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false)
{
Name = "Wild Honey"
@ -223,18 +191,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
honey.Name = $"{item.Name} Honey";
honey.Price += item.Price * 2;
}
#else
SObject honey = new SObject(Vector2.Zero, 340, item.Name + " Honey", false, true, false, false)
{
name = "Wild Honey",
honeyType = type
};
if (type != SObject.HoneyType.Wild)
{
honey.name = $"{item.Name} Honey";
honey.price += item.price * 2;
}
#endif
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 5 + id, honey);
}
}

View File

@ -7,7 +7,7 @@ using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands;
namespace StardewModdingAPI.Mods.ConsoleCommands
{
/// <summary>The main entry point for the mod.</summary>
public class ConsoleCommandsMod : Mod
public class ModEntry : Mod
{
/*********
** Properties

View File

@ -38,7 +38,7 @@
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
<Private>True</Private>
<Private>False</Private>
</Reference>
<Reference Include="System" />
<Reference Include="System.Xml" />
@ -56,8 +56,6 @@
<Compile Include="Framework\Commands\Player\AddCommand.cs" />
<Compile Include="Framework\Commands\Player\SetStyleCommand.cs" />
<Compile Include="Framework\Commands\Player\SetColorCommand.cs" />
<Compile Include="Framework\Commands\Player\SetSpeedCommand.cs" />
<Compile Include="Framework\Commands\Player\SetLevelCommand.cs" />
<Compile Include="Framework\Commands\Player\SetMaxHealthCommand.cs" />
<Compile Include="Framework\Commands\Player\SetMaxStaminaCommand.cs" />
<Compile Include="Framework\Commands\Player\SetHealthCommand.cs" />
@ -77,7 +75,7 @@
<Compile Include="Framework\Commands\ITrainerCommand.cs" />
<Compile Include="Framework\ItemData\SearchableItem.cs" />
<Compile Include="Framework\ItemRepository.cs" />
<Compile Include="ConsoleCommandsMod.cs" />
<Compile Include="ModEntry.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
</ItemGroup>
<ItemGroup>

View File

@ -1,7 +1,7 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
"Version": "2.5.5",
"Version": "2.6.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll"

View File

@ -0,0 +1,9 @@
namespace StardewModdingAPI.Mods.SaveBackup.Framework
{
/// <summary>The mod configuration.</summary>
internal class ModConfig
{
/// <summary>The number of backups to keep.</summary>
public int BackupsToKeep { get; set; } = 10;
}
}

View File

@ -0,0 +1,133 @@
using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using StardewModdingAPI.Mods.SaveBackup.Framework;
using StardewValley;
namespace StardewModdingAPI.Mods.SaveBackup
{
/// <summary>The main entry point for the mod.</summary>
public class ModEntry : Mod
{
/*********
** Properties
*********/
/// <summary>The name of the save archive to create.</summary>
private readonly string FileName = $"{DateTime.UtcNow:yyyy-MM-dd} - SMAPI {Constants.ApiVersion} with Stardew Valley {Game1.version}.zip";
/*********
** Public methods
*********/
/// <summary>The mod entry point, called after the mod is first loaded.</summary>
/// <param name="helper">Provides simplified APIs for writing mods.</param>
public override void Entry(IModHelper helper)
{
try
{
ModConfig config = this.Helper.ReadConfig<ModConfig>();
// init backup folder
DirectoryInfo backupFolder = new DirectoryInfo(Path.Combine(this.Helper.DirectoryPath, "backups"));
backupFolder.Create();
// back up saves
this.CreateBackup(backupFolder);
this.PruneBackups(backupFolder, config.BackupsToKeep);
}
catch (Exception ex)
{
this.Monitor.Log($"Error backing up saves: {ex}");
}
}
/*********
** Private methods
*********/
/// <summary>Back up the current saves.</summary>
/// <param name="backupFolder">The folder containing save backups.</param>
private void CreateBackup(DirectoryInfo backupFolder)
{
try
{
// get target path
FileInfo targetFile = new FileInfo(Path.Combine(backupFolder.FullName, this.FileName));
if (targetFile.Exists)
targetFile.Delete(); //return;
// create zip
// due to limitations with the bundled Mono on Mac, we can't reference System.IO.Compression.
this.Monitor.Log($"Adding {targetFile.Name}...", LogLevel.Trace);
switch (Constants.TargetPlatform)
{
case GamePlatform.Linux:
case GamePlatform.Windows:
{
Assembly coreAssembly = Assembly.Load("System.IO.Compression, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
Assembly fsAssembly = Assembly.Load("System.IO.Compression.FileSystem, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089") ?? throw new InvalidOperationException("Can't load System.IO.Compression assembly.");
Type compressionLevelType = coreAssembly.GetType("System.IO.Compression.CompressionLevel") ?? throw new InvalidOperationException("Can't load CompressionLevel type.");
Type zipFileType = fsAssembly.GetType("System.IO.Compression.ZipFile") ?? throw new InvalidOperationException("Can't load ZipFile type.");
MethodInfo createMethod = zipFileType.GetMethod("CreateFromDirectory", new[] { typeof(string), typeof(string), compressionLevelType, typeof(bool) }) ?? throw new InvalidOperationException("Can't load ZipFile.CreateFromDirectory method.");
createMethod.Invoke(null, new object[] { Constants.SavesPath, targetFile.FullName, CompressionLevel.Fastest, false });
}
break;
case GamePlatform.Mac:
{
DirectoryInfo saveFolder = new DirectoryInfo(Constants.SavesPath);
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = "zip",
Arguments = $"-rq \"{targetFile.FullName}\" \"{saveFolder.Name}\" -x \"*.DS_Store\" -x \"__MACOSX\"",
WorkingDirectory = $"{Constants.SavesPath}/../",
CreateNoWindow = true
};
new Process { StartInfo = startInfo }.Start();
}
break;
}
}
catch (Exception ex)
{
this.Monitor.Log("Couldn't back up save files (see log file for details).", LogLevel.Warn);
this.Monitor.Log(ex.ToString(), LogLevel.Trace);
}
}
/// <summary>Remove old backups if we've exceeded the limit.</summary>
/// <param name="backupFolder">The folder containing save backups.</param>
/// <param name="backupsToKeep">The number of backups to keep.</param>
private void PruneBackups(DirectoryInfo backupFolder, int backupsToKeep)
{
try
{
var oldBackups = backupFolder
.GetFiles()
.OrderByDescending(p => p.CreationTimeUtc)
.Skip(backupsToKeep);
foreach (FileInfo file in oldBackups)
{
try
{
this.Monitor.Log($"Deleting {file.Name}...", LogLevel.Trace);
file.Delete();
}
catch (Exception ex)
{
this.Monitor.Log($"Error deleting old save backup '{file.Name}': {ex}");
}
}
}
catch (Exception ex)
{
this.Monitor.Log("Couldn't remove old backups (see log file for details).", LogLevel.Warn);
this.Monitor.Log(ex.ToString(), LogLevel.Trace);
}
}
}
}

View File

@ -0,0 +1,4 @@
using System.Reflection;
[assembly: AssemblyTitle("StardewModdingAPI.Mods.SaveBackup")]
[assembly: AssemblyDescription("")]

View File

@ -1,30 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">x86</Platform>
<ProjectGuid>{10DB0676-9FC1-4771-A2C8-E2519F091E49}</ProjectGuid>
<ProjectGuid>{E272EB5D-8C57-417E-8E60-C1079D3F53C4}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>StardewModdingAPI.AssemblyRewriters</RootNamespace>
<AssemblyName>StardewModdingAPI.AssemblyRewriters</AssemblyName>
<RootNamespace>StardewModdingAPI.Mods.SaveBackup</RootNamespace>
<AssemblyName>SaveBackup</AssemblyName>
<TargetFrameworkVersion>v4.5</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x86'">
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|x86' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<OutputPath>$(SolutionDir)\..\bin\Debug\Mods\SaveBackup\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Release|x86'">
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|x86' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<OutputPath>$(SolutionDir)\..\bin\Release\Mods\SaveBackup\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
@ -36,8 +36,26 @@
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Framework\ModConfig.cs" />
<Compile Include="ModEntry.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="SpriteBatchMethods.cs" />
</ItemGroup>
<ItemGroup>
<None Include="manifest.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\SMAPI\StardewModdingAPI.csproj">
<Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project>
<Name>StardewModdingAPI</Name>
<Private>False</Private>
</ProjectReference>
<ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj">
<Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project>
<Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name>
<Private>False</Private>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<Import Project="..\..\build\common.targets" />

View File

@ -0,0 +1,8 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
"Version": "2.6.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll"
}

View File

@ -6,10 +6,10 @@ using Moq;
using Newtonsoft.Json;
using NUnit.Framework;
using StardewModdingAPI.Framework;
using StardewModdingAPI.Framework.ModData;
using StardewModdingAPI.Framework.Models;
using StardewModdingAPI.Framework.ModLoading;
using StardewModdingAPI.Framework.Serialisation;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Toolkit.Serialisation.Models;
namespace StardewModdingAPI.Tests.Core
{
@ -31,7 +31,7 @@ namespace StardewModdingAPI.Tests.Core
Directory.CreateDirectory(rootFolder);
// act
IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
// assert
Assert.AreEqual(0, mods.Length, 0, $"Expected to find zero manifests, found {mods.Length} instead.");
@ -46,7 +46,7 @@ namespace StardewModdingAPI.Tests.Core
Directory.CreateDirectory(modFolder);
// act
IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
// assert
@ -85,7 +85,7 @@ namespace StardewModdingAPI.Tests.Core
File.WriteAllText(filename, JsonConvert.SerializeObject(original));
// act
IModMetadata[] mods = new ModResolver().ReadManifests(rootFolder, new JsonHelper(), new ModDatabase()).ToArray();
IModMetadata[] mods = new ModResolver().ReadManifests(new ModToolkit(), rootFolder, new ModDatabase()).ToArray();
IModMetadata mod = mods.FirstOrDefault();
// assert
@ -93,8 +93,8 @@ namespace StardewModdingAPI.Tests.Core
Assert.IsNotNull(mod, "The loaded manifest shouldn't be null.");
Assert.AreEqual(null, mod.DataRecord, "The data record should be null since we didn't provide one.");
Assert.AreEqual(modFolder, mod.DirectoryPath, "The directory path doesn't match.");
Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match.");
Assert.AreEqual(null, mod.Error, "The error should be null since parsing should have succeeded.");
Assert.AreEqual(ModMetadataStatus.Found, mod.Status, "The status doesn't match.");
Assert.AreEqual(original[nameof(IManifest.Name)], mod.DisplayName, "The display name should use the manifest name.");
Assert.AreEqual(original[nameof(IManifest.Name)], mod.Manifest.Name, "The manifest's name doesn't match.");
@ -142,7 +142,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
this.SetupMetadataForValidation(mock, new ParsedModDataRecord
this.SetupMetadataForValidation(mock, new ModDataRecordVersionedFields
{
Status = ModStatus.AssumeBroken,
AlternativeUrl = "http://example.org"
@ -160,7 +160,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
mock.Setup(p => p.Manifest).Returns(this.GetManifest(m => m.MinimumApiVersion = new SemanticVersion("1.1")));
mock.Setup(p => p.Manifest).Returns(this.GetManifest(minimumApiVersion: "1.1"));
this.SetupMetadataForValidation(mock);
// act
@ -174,7 +174,7 @@ namespace StardewModdingAPI.Tests.Core
public void ValidateManifests_MissingEntryDLL_Fails()
{
// arrange
Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.EntryDll = "Missing.dll"), allowStatusChange: true);
Mock<IModMetadata> mock = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0", entryDll: "Missing.dll"), allowStatusChange: true);
this.SetupMetadataForValidation(mock);
// act
@ -189,7 +189,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
Mock<IModMetadata> modA = this.GetMetadata("Mod A", new string[0], allowStatusChange: true);
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod A", "1.0", manifest => manifest.Name = "Mod B"), allowStatusChange: true);
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod A", name: "Mod B", version: "1.0"), allowStatusChange: true);
Mock<IModMetadata> modC = this.GetMetadata("Mod C", new string[0], allowStatusChange: false);
foreach (Mock<IModMetadata> mod in new[] { modA, modB, modC })
this.SetupMetadataForValidation(mod);
@ -398,8 +398,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A 1.0 ◀── B (need A 1.1)
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.1")), allowStatusChange: true);
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.1") }), allowStatusChange: true);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray();
@ -414,8 +414,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A 1.0 ◀── B (need A 1.0-beta)
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0-beta")), allowStatusChange: false);
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0-beta") }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modA.Object, modB.Object }, new ModDatabase()).ToArray();
@ -431,8 +431,8 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A ◀── B
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest("Mod A", "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false);
Mock<IModMetadata> modA = this.GetMetadata(this.GetManifest(id: "Mod A", version: "1.0"));
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object, modA.Object }, new ModDatabase()).ToArray();
@ -448,7 +448,7 @@ namespace StardewModdingAPI.Tests.Core
{
// arrange
// A ◀── B where A doesn't exist
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest("Mod B", "1.0", new ManifestDependency("Mod A", "1.0", required: false)), allowStatusChange: false);
Mock<IModMetadata> modB = this.GetMetadata(this.GetManifest(id: "Mod B", version: "1.0", dependencies: new IManifestDependency[] { new ManifestDependency("Mod A", "1.0", required: false) }), allowStatusChange: false);
// act
IModMetadata[] mods = new ModResolver().ProcessDependencies(new[] { modB.Object }, new ModDatabase()).ToArray();
@ -463,46 +463,27 @@ namespace StardewModdingAPI.Tests.Core
** Private methods
*********/
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="adjust">Adjust the generated manifest.</param>
private Manifest GetManifest(Action<Manifest> adjust = null)
/// <param name="id">The <see cref="IManifest.UniqueID"/> value, or <c>null</c> for a generated value.</param>
/// <param name="name">The <see cref="IManifest.Name"/> value, or <c>null</c> for a generated value.</param>
/// <param name="version">The <see cref="IManifest.Version"/> value, or <c>null</c> for a generated value.</param>
/// <param name="entryDll">The <see cref="IManifest.EntryDll"/> value, or <c>null</c> for a generated value.</param>
/// <param name="contentPackForID">The <see cref="IManifest.ContentPackFor"/> value.</param>
/// <param name="minimumApiVersion">The <see cref="IManifest.MinimumApiVersion"/> value.</param>
/// <param name="dependencies">The <see cref="IManifest.Dependencies"/> value.</param>
private Manifest GetManifest(string id = null, string name = null, string version = null, string entryDll = null, string contentPackForID = null, string minimumApiVersion = null, IManifestDependency[] dependencies = null)
{
Manifest manifest = new Manifest
return new Manifest
{
Name = Sample.String(),
UniqueID = id ?? $"{Sample.String()}.{Sample.String()}",
Name = name ?? id ?? Sample.String(),
Author = Sample.String(),
Version = new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
Description = Sample.String(),
UniqueID = $"{Sample.String()}.{Sample.String()}",
EntryDll = $"{Sample.String()}.dll"
Version = version != null ? new SemanticVersion(version) : new SemanticVersion(Sample.Int(), Sample.Int(), Sample.Int(), Sample.String()),
EntryDll = entryDll ?? $"{Sample.String()}.dll",
ContentPackFor = contentPackForID != null ? new ManifestContentPackFor { UniqueID = contentPackForID } : null,
MinimumApiVersion = minimumApiVersion != null ? new SemanticVersion(minimumApiVersion) : null,
Dependencies = dependencies
};
adjust?.Invoke(manifest);
return manifest;
}
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="uniqueID">The mod's name and unique ID.</param>
/// <param name="version">The mod version.</param>
/// <param name="adjust">Adjust the generated manifest.</param>
/// <param name="dependencies">The dependencies this mod requires.</param>
private IManifest GetManifest(string uniqueID, string version, Action<Manifest> adjust, params IManifestDependency[] dependencies)
{
return this.GetManifest(manifest =>
{
manifest.Name = uniqueID;
manifest.UniqueID = uniqueID;
manifest.Version = new SemanticVersion(version);
manifest.Dependencies = dependencies;
adjust?.Invoke(manifest);
});
}
/// <summary>Get a randomised basic manifest.</summary>
/// <param name="uniqueID">The mod's name and unique ID.</param>
/// <param name="version">The mod version.</param>
/// <param name="dependencies">The dependencies this mod requires.</param>
private IManifest GetManifest(string uniqueID, string version, params IManifestDependency[] dependencies)
{
return this.GetManifest(uniqueID, version, null, dependencies);
}
/// <summary>Get a randomised basic manifest.</summary>
@ -518,7 +499,7 @@ namespace StardewModdingAPI.Tests.Core
/// <param name="allowStatusChange">Whether the code being tested is allowed to change the mod status.</param>
private Mock<IModMetadata> GetMetadata(string uniqueID, string[] dependencies, bool allowStatusChange = false)
{
IManifest manifest = this.GetManifest(uniqueID, "1.0", dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
IManifest manifest = this.GetManifest(id: uniqueID, version: "1.0", dependencies: dependencies?.Select(dependencyID => (IManifestDependency)new ManifestDependency(dependencyID, null)).ToArray());
return this.GetMetadata(manifest, allowStatusChange);
}
@ -545,7 +526,7 @@ namespace StardewModdingAPI.Tests.Core
/// <summary>Set up a mock mod metadata for <see cref="ModResolver.ValidateManifests"/>.</summary>
/// <param name="mod">The mock mod metadata.</param>
/// <param name="modRecord">The extra metadata about the mod from SMAPI's internal data (if any).</param>
private void SetupMetadataForValidation(Mock<IModMetadata> mod, ParsedModDataRecord modRecord = null)
private void SetupMetadataForValidation(Mock<IModMetadata> mod, ModDataRecordVersionedFields modRecord = null)
{
mod.Setup(p => p.Status).Returns(ModMetadataStatus.Found);
mod.Setup(p => p.DataRecord).Returns(() => null);

View File

@ -32,10 +32,10 @@
</PropertyGroup>
<ItemGroup>
<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>
<HintPath>..\packages\Castle.Core.4.3.1\lib\net45\Castle.Core.dll</HintPath>
</Reference>
<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>
<HintPath>..\packages\Moq.4.8.3\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.2\lib\net45\Newtonsoft.Json.dll</HintPath>
@ -45,18 +45,21 @@
</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 Include="System.Runtime.CompilerServices.Unsafe, Version=4.0.4.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<HintPath>..\packages\System.Runtime.CompilerServices.Unsafe.4.5.1\lib\netstandard1.0\System.Runtime.CompilerServices.Unsafe.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 Include="System.Threading.Tasks.Extensions, Version=4.2.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>..\packages\System.Threading.Tasks.Extensions.4.5.1\lib\portable-net45+win8+wp8+wpa81\System.Threading.Tasks.Extensions.dll</HintPath>
</Reference>
<Reference Include="System.ValueTuple, Version=4.0.3.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51, processorArchitecture=MSIL">
<HintPath>..\packages\System.ValueTuple.4.5.0\lib\netstandard1.0\System.ValueTuple.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Compile Include="..\..\build\GlobalAssemblyInfo.cs">
<Link>Properties\GlobalAssemblyInfo.cs</Link>
</Compile>
<Compile Include="Core\PathUtilitiesTests.cs" />
<Compile Include="Toolkit\PathUtilitiesTests.cs" />
<Compile Include="Utilities\SemanticVersionTests.cs" />
<Compile Include="Utilities\SDateTests.cs" />
<Compile Include="Core\TranslationTests.cs" />
@ -73,6 +76,14 @@
<Project>{f1a573b0-f436-472c-ae29-0b91ea6b9f8f}</Project>
<Name>StardewModdingAPI</Name>
</ProjectReference>
<ProjectReference Include="..\StardewModdingAPI.Toolkit.CoreInterfaces\StardewModdingAPI.Toolkit.CoreInterfaces.csproj">
<Project>{d5cfd923-37f1-4bc3-9be8-e506e202ac28}</Project>
<Name>StardewModdingAPI.Toolkit.CoreInterfaces</Name>
</ProjectReference>
<ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj">
<Project>{ea5cfd2e-9453-4d29-b80f-8e0ea23f4ac6}</Project>
<Name>StardewModdingAPI.Toolkit</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Service Include="{82A7F48D-3B50-4B1E-B82E-3ADA8210C358}" />

View File

@ -1,7 +1,7 @@
using NUnit.Framework;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Toolkit.Utilities;
namespace StardewModdingAPI.Tests.Core
namespace StardewModdingAPI.Tests.Toolkit
{
/// <summary>Unit tests for <see cref="PathUtilities"/>.</summary>
[TestFixture]

View File

@ -22,6 +22,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")]
[TestCase("1.2-some-tag.4", ExpectedResult = "1.2-some-tag.4")]
[TestCase("1.2.3-some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
[TestCase("1.2.3-SoME-tAg.4", ExpectedResult = "1.2.3-SoME-tAg.4")]
[TestCase("1.2.3-some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
public string Constructor_FromString(string input)
{
@ -35,6 +36,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, "0", ExpectedResult = "1.2.3-0")]
[TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "sOMe-TaG.4", ExpectedResult = "1.2.3-sOMe-TaG.4")]
[TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
public string Constructor_FromParts(int major, int minor, int patch, string tag)
{
@ -49,6 +51,19 @@ namespace StardewModdingAPI.Tests.Utilities
return version.ToString();
}
[Test(Description = "Assert that the constructor throws the expected exception for invalid versions when constructed from the individual numbers.")]
[TestCase(0, 0, 0, null)]
[TestCase(-1, 0, 0, null)]
[TestCase(0, -1, 0, null)]
[TestCase(0, 0, -1, null)]
[TestCase(1, 0, 0, "-tag")]
[TestCase(1, 0, 0, "tag spaces")]
[TestCase(1, 0, 0, "tag~")]
public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string tag)
{
this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, tag));
}
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from an assembly version.")]
[TestCase(1, 0, 0, ExpectedResult = "1.0")]
[TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
@ -79,6 +94,7 @@ namespace StardewModdingAPI.Tests.Utilities
[TestCase("1.2.3.apple")]
[TestCase("1..2..3")]
[TestCase("1.2.3-")]
[TestCase("1.2.3--some-tag")]
[TestCase("1.2.3-some-tag...")]
[TestCase("1.2.3-some-tag...4")]
[TestCase("apple")]
@ -271,22 +287,6 @@ namespace StardewModdingAPI.Tests.Utilities
Assert.IsTrue(version.IsOlderThan(new SemanticVersion("1.2.30")), "The game version should be considered older than the later semantic versions.");
}
/****
** LegacyManifestVersion
****/
[Test(Description = "Assert that the LegacyManifestVersion subclass correctly parses legacy manifest versions.")]
[TestCase(1, 0, 0, null, ExpectedResult = "1.0")]
[TestCase(3000, 4000, 5000, null, ExpectedResult = "3000.4000.5000")]
[TestCase(1, 2, 3, "", ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, " ", ExpectedResult = "1.2.3")]
[TestCase(1, 2, 3, "0", ExpectedResult = "1.2.3")] // special case: drop '0' tag for legacy manifest versions
[TestCase(1, 2, 3, "some-tag.4", ExpectedResult = "1.2.3-some-tag.4")]
[TestCase(1, 2, 3, "some-tag.4 ", ExpectedResult = "1.2.3-some-tag.4")]
public string LegacyManifestVersion(int major, int minor, int patch, string tag)
{
return new LegacyManifestVersion(major, minor, patch, tag).ToString();
}
/*********
** Private methods

View File

@ -4,7 +4,11 @@
<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" />
<bindingRedirect oldVersion="0.0.0.0-4.2.0.0" newVersion="4.2.0.0" />
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.ValueTuple" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral" />
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0" />
</dependentAssembly>
</assemblyBinding>
</runtime>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Castle.Core" version="4.2.1" targetFramework="net45" />
<package id="Moq" version="4.8.2" targetFramework="net45" />
<package id="Castle.Core" version="4.3.1" targetFramework="net45" />
<package id="Moq" version="4.8.3" 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" />
<package id="System.Runtime.CompilerServices.Unsafe" version="4.5.1" targetFramework="net45" />
<package id="System.Threading.Tasks.Extensions" version="4.5.1" targetFramework="net45" />
<package id="System.ValueTuple" version="4.5.0" targetFramework="net45" />
</packages>

View File

@ -1,10 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using StardewModdingAPI.Common;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@ -17,6 +22,9 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
/// <summary>The site config settings.</summary>
private readonly SiteConfig SiteConfig;
/// <summary>The cache in which to store release data.</summary>
private readonly IMemoryCache Cache;
@ -24,7 +32,7 @@ namespace StardewModdingAPI.Web.Controllers
private readonly IGitHubClient GitHub;
/// <summary>The cache time for release info.</summary>
private readonly TimeSpan CacheTime = TimeSpan.FromSeconds(1);
private readonly TimeSpan CacheTime = TimeSpan.FromMinutes(10);
/// <summary>The GitHub repository name to check for update.</summary>
private readonly string RepositoryName = "Pathoschild/SMAPI";
@ -36,34 +44,35 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>Construct an instance.</summary>
/// <param name="cache">The cache in which to store release data.</param>
/// <param name="github">The GitHub API client.</param>
public IndexController(IMemoryCache cache, IGitHubClient github)
/// <param name="siteConfig">The context config settings.</param>
public IndexController(IMemoryCache cache, IGitHubClient github, IOptions<SiteConfig> siteConfig)
{
this.Cache = cache;
this.GitHub = github;
this.SiteConfig = siteConfig.Value;
}
/// <summary>Display the index page.</summary>
[HttpGet]
public async Task<ViewResult> Index()
{
// fetch SMAPI releases
IndexVersionModel stableVersion = await this.Cache.GetOrCreateAsync("stable-version", async entry =>
{
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
return new IndexVersionModel(release.Name, release.Body, this.GetMainDownloadUrl(release), this.GetDevDownloadUrl(release));
});
IndexVersionModel betaVersion = await this.Cache.GetOrCreateAsync("beta-version", async entry =>
{
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
GitRelease release = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
return release.IsPrerelease
? this.GetBetaDownload(release)
: null;
});
// choose versions
ReleaseVersion[] versions = await this.GetReleaseVersionsAsync();
ReleaseVersion stableVersion = versions.LastOrDefault(version => !version.IsBeta && !version.IsForDevs);
ReleaseVersion stableVersionForDevs = versions.LastOrDefault(version => !version.IsBeta && version.IsForDevs);
ReleaseVersion betaVersion = versions.LastOrDefault(version => version.IsBeta && !version.IsForDevs);
ReleaseVersion betaVersionForDevs = versions.LastOrDefault(version => version.IsBeta && version.IsForDevs);
// render view
var model = new IndexModel(stableVersion, betaVersion);
IndexVersionModel stableVersionModel = stableVersion != null
? new IndexVersionModel(stableVersion.Version.ToString(), stableVersion.Release.Body, stableVersion.Asset.DownloadUrl, stableVersionForDevs?.Asset.DownloadUrl)
: new IndexVersionModel("unknown", "", "https://github.com/Pathoschild/SMAPI/releases", null); // just in case something goes wrong)
IndexVersionModel betaVersionModel = betaVersion != null && this.SiteConfig.EnableSmapiBeta
? new IndexVersionModel(betaVersion.Version.ToString(), betaVersion.Release.Body, betaVersion.Asset.DownloadUrl, betaVersionForDevs?.Asset.DownloadUrl)
: null;
// render view
var model = new IndexModel(stableVersionModel, betaVersionModel);
return this.View(model);
}
@ -71,62 +80,109 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Private methods
*********/
/// <summary>Get the main download URL for a SMAPI release.</summary>
/// <param name="release">The SMAPI release.</param>
private string GetMainDownloadUrl(GitRelease release)
/// <summary>Get a sorted, parsed list of SMAPI downloads for the latest releases.</summary>
private async Task<ReleaseVersion[]> GetReleaseVersionsAsync()
{
// get main download URL
foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
return await this.Cache.GetOrCreateAsync("available-versions", async entry =>
{
if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer.zip"))
return asset.DownloadUrl;
}
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Add(this.CacheTime);
// fallback just in case
return "https://github.com/pathoschild/SMAPI/releases";
}
// get latest release (whether preview or stable)
GitRelease stableRelease = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: true);
/// <summary>Get the for-developers download URL for a SMAPI release.</summary>
/// <param name="release">The SMAPI release.</param>
private string GetDevDownloadUrl(GitRelease release)
{
// get dev download URL
foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
{
if (Regex.IsMatch(asset.FileName, @"SMAPI-[\d\.]+-installer-for-developers.zip"))
return asset.DownloadUrl;
}
// fallback just in case
return "https://github.com/pathoschild/SMAPI/releases";
}
/// <summary>Get the latest beta download for a SMAPI release.</summary>
/// <param name="release">The SMAPI release.</param>
private IndexVersionModel GetBetaDownload(GitRelease release)
{
// get download with the latest version
SemanticVersionImpl latestVersion = null;
string latestUrl = null;
foreach (GitAsset asset in release.Assets ?? new GitAsset[0])
{
// parse version
Match versionMatch = Regex.Match(asset.FileName, @"SMAPI-([\d\.]+(?:-.+)?)-installer.zip");
if (!versionMatch.Success || !SemanticVersionImpl.TryParse(versionMatch.Groups[1].Value, out SemanticVersionImpl version))
continue;
// save latest version
if (latestVersion == null || latestVersion.CompareTo(version) < 0)
// split stable/prerelease if applicable
GitRelease betaRelease = null;
if (stableRelease.IsPrerelease)
{
latestVersion = version;
latestUrl = asset.DownloadUrl;
GitRelease result = await this.GitHub.GetLatestReleaseAsync(this.RepositoryName, includePrerelease: false);
if (result != null)
{
betaRelease = stableRelease;
stableRelease = result;
}
}
}
// return if prerelease
return latestVersion?.Tag != null
? new IndexVersionModel(latestVersion.ToString(), release.Body, latestUrl, null)
: null;
// strip 'noinclude' blocks from release descriptions
foreach (GitRelease release in new[] { stableRelease, betaRelease })
{
if (release == null)
continue;
HtmlDocument doc = new HtmlDocument();
doc.LoadHtml(release.Body);
foreach (HtmlNode node in doc.DocumentNode.SelectNodes("//*[@class='noinclude']")?.ToArray() ?? new HtmlNode[0])
node.Remove();
release.Body = doc.DocumentNode.InnerHtml.Trim();
}
// get versions
ReleaseVersion[] stableVersions = this.ParseReleaseVersions(stableRelease).ToArray();
ReleaseVersion[] betaVersions = this.ParseReleaseVersions(betaRelease).ToArray();
return stableVersions
.Concat(betaVersions)
.OrderBy(p => p.Version)
.ToArray();
});
}
/// <summary>Get a parsed list of SMAPI downloads for a release.</summary>
/// <param name="release">The GitHub release.</param>
private IEnumerable<ReleaseVersion> ParseReleaseVersions(GitRelease release)
{
if (release?.Assets == null)
yield break;
foreach (GitAsset asset in release.Assets)
{
Match match = Regex.Match(asset.FileName, @"SMAPI-(?<version>[\d\.]+(?:-.+)?)-installer(?<forDevs>-for-developers)?.zip");
if (!match.Success || !SemanticVersion.TryParse(match.Groups["version"].Value, out ISemanticVersion version))
continue;
bool isBeta = version.IsPrerelease();
bool isForDevs = match.Groups["forDevs"].Success;
yield return new ReleaseVersion(release, asset, version, isBeta, isForDevs);
}
}
/// <summary>A parsed release download.</summary>
private class ReleaseVersion
{
/*********
** Accessors
*********/
/// <summary>The underlying GitHub release.</summary>
public GitRelease Release { get; }
/// <summary>The underlying download asset.</summary>
public GitAsset Asset { get; }
/// <summary>The SMAPI version.</summary>
public ISemanticVersion Version { get; }
/// <summary>Whether this is a beta download.</summary>
public bool IsBeta { get; }
/// <summary>Whether this is a 'for developers' download.</summary>
public bool IsForDevs { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="release">The underlying GitHub release.</param>
/// <param name="asset">The underlying download asset.</param>
/// <param name="version">The SMAPI version.</param>
/// <param name="isBeta">Whether this is a beta download.</param>
/// <param name="isForDevs">Whether this is a 'for developers' download.</param>
public ReleaseVersion(GitRelease release, GitAsset asset, ISemanticVersion version, bool isBeta, bool isForDevs)
{
this.Release = release;
this.Asset = asset;
this.Version = version;
this.IsBeta = isBeta;
this.IsForDevs = isForDevs;
}
}
}
}

View File

@ -1,6 +1,7 @@
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
@ -20,8 +21,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Properties
*********/
/// <summary>The log parser config settings.</summary>
private readonly ContextConfig Config;
/// <summary>The site config settings.</summary>
private readonly SiteConfig Config;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
@ -38,11 +39,11 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
/// <param name="contextProvider">The context config settings.</param>
/// <param name="siteConfig">The context config settings.</param>
/// <param name="pastebin">The Pastebin API client.</param>
public LogParserController(IOptions<ContextConfig> contextProvider, IPastebinClient pastebin)
public LogParserController(IOptions<SiteConfig> siteConfig, IPastebinClient pastebin)
{
this.Config = contextProvider.Value;
this.Config = siteConfig.Value;
this.Pastebin = pastebin;
}
@ -51,34 +52,49 @@ namespace StardewModdingAPI.Web.Controllers
***/
/// <summary>Render the log parser UI.</summary>
/// <param name="id">The paste ID.</param>
/// <param name="raw">Whether to display the raw unparsed log.</param>
[HttpGet]
[Route("log")]
[Route("log/{id}")]
public async Task<ViewResult> Index(string id = null)
public async Task<ViewResult> Index(string id = null, bool raw = false)
{
// fresh page
if (string.IsNullOrWhiteSpace(id))
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, null));
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id));
// log page
PasteInfo paste = await this.GetAsync(id);
ParsedLog log = paste.Success
? new LogParser().Parse(paste.Content)
: new ParsedLog { IsValid = false, Error = "Pastebin error: " + paste.Error };
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log));
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, id, log, raw));
}
/***
** JSON
***/
/// <summary>Save raw log data.</summary>
/// <param name="content">The log content to save.</param>
[HttpPost, Produces("application/json"), AllowLargePosts]
[Route("log/save")]
public async Task<SavePasteResult> PostAsync([FromBody] string content)
[HttpPost, AllowLargePosts]
[Route("log")]
public async Task<ActionResult> PostAsync()
{
content = this.CompressString(content);
return await this.Pastebin.PostAsync(content);
// get raw log text
string input = this.Request.Form["input"].FirstOrDefault();
if (string.IsNullOrWhiteSpace(input))
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, null) { UploadError = "The log file seems to be empty." });
// upload log
input = this.CompressString(input);
SavePasteResult result = await this.Pastebin.PostAsync(input);
// handle errors
if (!result.Success)
return this.View("Index", new LogParserModel(this.Config.LogParserUrl, result.ID) { UploadError = $"Pastebin error: {result.Error ?? "unknown error"}" });
// redirect to view
UriBuilder uri = new UriBuilder(new Uri(this.Config.LogParserUrl));
uri.Path = uri.Path.TrimEnd('/') + '/' + result.ID;
return this.Redirect(uri.Uri.ToString());
}
@ -115,7 +131,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// prefix length
var zipBuffer = new byte[compressedData.Length + 4];
byte[] zipBuffer = new byte[compressedData.Length + 4];
Buffer.BlockCopy(compressedData, 0, zipBuffer, 4, compressedData.Length);
Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, zipBuffer, 0, 4);
@ -151,7 +167,7 @@ namespace StardewModdingAPI.Web.Controllers
memoryStream.Write(zipBuffer, 4, zipBuffer.Length - 4);
// read data
var buffer = new byte[dataLength];
byte[] buffer = new byte[dataLength];
memoryStream.Position = 0;
using (GZipStream gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress))
gZipStream.Read(buffer, 0, buffer.Length);

View File

@ -1,12 +1,17 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
using StardewModdingAPI.Toolkit.Framework.ModData;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
@ -38,19 +43,28 @@ namespace StardewModdingAPI.Web.Controllers
/// <summary>A regex which matches SMAPI-style semantic version.</summary>
private readonly string VersionRegex;
/// <summary>The internal mod metadata list.</summary>
private readonly ModDatabase ModDatabase;
/// <summary>The web URL for the wiki compatibility list.</summary>
private readonly string WikiCompatibilityPageUrl;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="environment">The web hosting environment.</param>
/// <param name="cache">The cache in which to store mod metadata.</param>
/// <param name="configProvider">The config settings for mod update checks.</param>
/// <param name="chucklefish">The Chucklefish API client.</param>
/// <param name="github">The GitHub API client.</param>
/// <param name="nexus">The Nexus API client.</param>
public ModsApiController(IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
public ModsApiController(IHostingEnvironment environment, IMemoryCache cache, IOptions<ModUpdateCheckConfig> configProvider, IChucklefishClient chucklefish, IGitHubClient github, INexusClient nexus)
{
this.ModDatabase = new ModToolkit().GetModDatabase(Path.Combine(environment.WebRootPath, "StardewModdingAPI.metadata.json"));
ModUpdateCheckConfig config = configProvider.Value;
this.WikiCompatibilityPageUrl = config.WikiCompatibilityPageUrl;
this.Cache = cache;
this.SuccessCacheMinutes = config.SuccessCacheMinutes;
@ -67,76 +81,126 @@ namespace StardewModdingAPI.Web.Controllers
}
/// <summary>Fetch version metadata for the given mods.</summary>
/// <param name="modKeys">The namespaced mod keys to search as a comma-delimited array.</param>
/// <param name="allowInvalidVersions">Whether to allow non-semantic versions, instead of returning an error for those.</param>
[HttpGet]
public async Task<IDictionary<string, ModInfoModel>> GetAsync(string modKeys, bool allowInvalidVersions = false)
{
string[] modKeysArray = modKeys?.Split(',').ToArray();
if (modKeysArray == null || !modKeysArray.Any())
return new Dictionary<string, ModInfoModel>();
return await this.PostAsync(new ModSearchModel(modKeysArray, allowInvalidVersions));
}
/// <summary>Fetch version metadata for the given mods.</summary>
/// <param name="search">The mod search criteria.</param>
/// <param name="model">The mod search criteria.</param>
[HttpPost]
public async Task<IDictionary<string, ModInfoModel>> PostAsync([FromBody] ModSearchModel search)
public async Task<object> PostAsync([FromBody] ModSearchModel model)
{
// parse model
bool allowInvalidVersions = search?.AllowInvalidVersions ?? false;
string[] modKeys = (search?.ModKeys?.ToArray() ?? new string[0])
.Distinct(StringComparer.CurrentCultureIgnoreCase)
.OrderBy(p => p, StringComparer.CurrentCultureIgnoreCase)
.ToArray();
// parse request data
ISemanticVersion apiVersion = this.GetApiVersion();
ModSearchEntryModel[] searchMods = this.GetSearchMods(model, apiVersion).ToArray();
// fetch mod info
IDictionary<string, ModInfoModel> result = new Dictionary<string, ModInfoModel>(StringComparer.CurrentCultureIgnoreCase);
foreach (string modKey in modKeys)
// fetch wiki data
WikiCompatibilityEntry[] wikiData = await this.GetWikiDataAsync();
// fetch data
IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
foreach (ModSearchEntryModel mod in searchMods)
{
// parse mod key
if (!this.TryParseModKey(modKey, out string vendorKey, out string modID))
{
result[modKey] = new ModInfoModel("The mod key isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
if (string.IsNullOrWhiteSpace(mod.ID))
continue;
}
// get matching repository
if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
{
result[modKey] = new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
continue;
}
// fetch mod info
result[modKey] = await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
{
// fetch info
ModInfoModel info = await repository.GetModInfoAsync(modID);
// validate
if (info.Error == null)
{
if (info.Version == null)
info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: "Mod has no version number.");
if (!allowInvalidVersions && !Regex.IsMatch(info.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
info = new ModInfoModel(name: info.Name, version: info.Version, url: info.Url, error: $"Mod has invalid semantic version '{info.Version}'.");
}
// cache & return
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(info.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
return info;
});
ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata);
result.SetBackwardsCompatibility(apiVersion);
mods[mod.ID] = result;
}
return result;
// return in expected structure
return apiVersion.IsNewerThan("2.6-beta.18")
? mods.Values
: (object)mods;
}
/*********
** Private methods
*********/
/// <summary>Get the metadata for a mod.</summary>
/// <param name="search">The mod data to match.</param>
/// <param name="wikiData">The wiki data.</param>
/// <param name="includeExtendedMetadata">Whether to include extended metadata for each mod.</param>
/// <returns>Returns the mod data if found, else <c>null</c>.</returns>
private async Task<ModEntryModel> GetModData(ModSearchEntryModel search, WikiCompatibilityEntry[] wikiData, bool includeExtendedMetadata)
{
// resolve update keys
var updateKeys = new HashSet<string>(search.UpdateKeys ?? new string[0], StringComparer.InvariantCultureIgnoreCase);
ModDataRecord record = this.ModDatabase.Get(search.ID);
if (record?.Fields != null)
{
string defaultUpdateKey = record.Fields.FirstOrDefault(p => p.Key == ModDataFieldKey.UpdateKey && p.IsDefault)?.Value;
if (!string.IsNullOrWhiteSpace(defaultUpdateKey))
updateKeys.Add(defaultUpdateKey);
}
// get latest versions
ModEntryModel result = new ModEntryModel { ID = search.ID };
IList<string> errors = new List<string>();
foreach (string updateKey in updateKeys)
{
// fetch data
ModInfoModel data = await this.GetInfoForUpdateKeyAsync(updateKey);
if (data.Error != null)
{
errors.Add(data.Error);
continue;
}
// handle main version
if (data.Version != null)
{
if (!SemanticVersion.TryParse(data.Version, out ISemanticVersion version))
{
errors.Add($"The update key '{updateKey}' matches a mod with invalid semantic version '{data.Version}'.");
continue;
}
if (this.IsNewer(version, result.Main?.Version))
result.Main = new ModEntryVersionModel(version, data.Url);
}
// handle optional version
if (data.PreviewVersion != null)
{
if (!SemanticVersion.TryParse(data.PreviewVersion, out ISemanticVersion version))
{
errors.Add($"The update key '{updateKey}' matches a mod with invalid optional semantic version '{data.PreviewVersion}'.");
continue;
}
if (this.IsNewer(version, result.Optional?.Version))
result.Optional = new ModEntryVersionModel(version, data.Url);
}
}
// get unofficial version
WikiCompatibilityEntry wikiEntry = wikiData.FirstOrDefault(entry => entry.ID.Contains(result.ID.Trim(), StringComparer.InvariantCultureIgnoreCase));
if (wikiEntry?.UnofficialVersion != null && this.IsNewer(wikiEntry.UnofficialVersion, result.Main?.Version) && this.IsNewer(wikiEntry.UnofficialVersion, result.Optional?.Version))
result.Unofficial = new ModEntryVersionModel(wikiEntry.UnofficialVersion, this.WikiCompatibilityPageUrl);
// fallback to preview if latest is invalid
if (result.Main == null && result.Optional != null)
{
result.Main = result.Optional;
result.Optional = null;
}
// special cases
if (result.ID == "Pathoschild.SMAPI")
{
if (result.Main != null)
result.Main.Url = "https://smapi.io/";
if (result.Optional != null)
result.Optional.Url = "https://smapi.io/";
}
// add extended metadata
if (includeExtendedMetadata && (wikiEntry != null || record != null))
result.Metadata = new ModExtendedMetadataModel(wikiEntry, record);
// add result
result.Errors = errors.ToArray();
return result;
}
/// <summary>Parse a namespaced mod ID.</summary>
/// <param name="raw">The raw mod ID to parse.</param>
/// <param name="vendorKey">The parsed vendor key.</param>
@ -158,5 +222,91 @@ namespace StardewModdingAPI.Web.Controllers
modID = parts[1].Trim();
return true;
}
/// <summary>Get whether a <paramref name="current"/> version is newer than an <paramref name="other"/> version.</summary>
/// <param name="current">The current version.</param>
/// <param name="other">The other version.</param>
private bool IsNewer(ISemanticVersion current, ISemanticVersion other)
{
return current != null && (other == null || other.IsOlderThan(current));
}
/// <summary>Get the mods for which the API should return data.</summary>
/// <param name="model">The search model.</param>
/// <param name="apiVersion">The requested API version.</param>
private IEnumerable<ModSearchEntryModel> GetSearchMods(ModSearchModel model, ISemanticVersion apiVersion)
{
if (model == null)
yield break;
// yield standard entries
if (model.Mods != null)
{
foreach (ModSearchEntryModel mod in model.Mods)
yield return mod;
}
// yield mod update keys if backwards compatible
if (model.ModKeys != null && model.ModKeys.Any() && !apiVersion.IsNewerThan("2.6-beta.17"))
{
foreach (string updateKey in model.ModKeys.Distinct())
yield return new ModSearchEntryModel(updateKey, new[] { updateKey });
}
}
/// <summary>Get mod data from the wiki compatibility list.</summary>
private async Task<WikiCompatibilityEntry[]> GetWikiDataAsync()
{
ModToolkit toolkit = new ModToolkit();
return await this.Cache.GetOrCreateAsync($"_wiki", async entry =>
{
try
{
WikiCompatibilityEntry[] entries = await toolkit.GetWikiCompatibilityListAsync();
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.SuccessCacheMinutes);
return entries;
}
catch
{
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(this.ErrorCacheMinutes);
return new WikiCompatibilityEntry[0];
}
});
}
/// <summary>Get the mod info for an update key.</summary>
/// <param name="updateKey">The namespaced update key.</param>
private async Task<ModInfoModel> GetInfoForUpdateKeyAsync(string updateKey)
{
// parse update key
if (!this.TryParseModKey(updateKey, out string vendorKey, out string modID))
return new ModInfoModel($"The update key '{updateKey}' isn't in a valid format. It should contain the site key and mod ID like 'Nexus:541'.");
// get matching repository
if (!this.Repositories.TryGetValue(vendorKey, out IModRepository repository))
return new ModInfoModel($"There's no mod site with key '{vendorKey}'. Expected one of [{string.Join(", ", this.Repositories.Keys)}].");
// fetch mod info
return await this.Cache.GetOrCreateAsync($"{repository.VendorKey}:{modID}".ToLower(), async entry =>
{
ModInfoModel result = await repository.GetModInfoAsync(modID);
if (result.Error != null)
{
if (result.Version == null)
result.Error = $"The update key '{updateKey}' matches a mod with no version number.";
else if (!Regex.IsMatch(result.Version, this.VersionRegex, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase))
result.Error = $"The update key '{updateKey}' matches a mod with invalid semantic version '{result.Version}'.";
}
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(result.Error == null ? this.SuccessCacheMinutes : this.ErrorCacheMinutes);
return result;
});
}
/// <summary>Get the requested API version.</summary>
private ISemanticVersion GetApiVersion()
{
string actualVersion = (string)this.RouteData.Values["version"];
return new SemanticVersion(actualVersion);
}
}
}

View File

@ -1,48 +0,0 @@
using System.Threading.Tasks;
using Pathoschild.Http.Client;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
/// <summary>An HTTP client for fetching mod metadata from the Nexus Mods API.</summary>
internal class NexusClient : INexusClient
{
/*********
** Properties
*********/
/// <summary>The URL for a Nexus Mods API query excluding the base URL, where {0} is the mod ID.</summary>
private readonly string ModUrlFormat;
/// <summary>The underlying HTTP client.</summary>
private readonly IClient Client;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
/// <param name="baseUrl">The base URL for the Nexus Mods API.</param>
/// <param name="modUrlFormat">The URL for a Nexus Mods API query excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
public NexusClient(string userAgent, string baseUrl, string modUrlFormat)
{
this.ModUrlFormat = modUrlFormat;
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
}
/// <summary>Get metadata about a mod.</summary>
/// <param name="id">The Nexus mod ID.</param>
/// <returns>Returns the mod info if found, else <c>null</c>.</returns>
public async Task<NexusMod> GetModAsync(uint id)
{
return await this.Client
.GetAsync(string.Format(this.ModUrlFormat, id))
.As<NexusMod>();
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose()
{
this.Client?.Dispose();
}
}
}

View File

@ -1,4 +1,5 @@
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
@ -14,6 +15,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>The mod's semantic version number.</summary>
public string Version { get; set; }
/// <summary>The latest file version.</summary>
public ISemanticVersion LatestFileVersion { get; set; }
/// <summary>The mod's web URL.</summary>
[JsonProperty("mod_page_uri")]
public string Url { get; set; }

View File

@ -1,8 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Pathoschild.Http.Client;
using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework.Clients.Nexus
{
@ -12,9 +15,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/*********
** Properties
*********/
/// <summary>The URL for a Nexus web page excluding the base URL, where {0} is the mod ID.</summary>
/// <summary>The URL for a Nexus mod page for the user, excluding the base URL, where {0} is the mod ID.</summary>
private readonly string ModUrlFormat;
/// <summary>The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</summary>
public string ModScrapeUrlFormat { get; set; }
/// <summary>The underlying HTTP client.</summary>
private readonly IClient Client;
@ -25,10 +31,12 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
/// <summary>Construct an instance.</summary>
/// <param name="userAgent">The user agent for the Nexus Mods API client.</param>
/// <param name="baseUrl">The base URL for the Nexus Mods site.</param>
/// <param name="modUrlFormat">The URL for a Nexus Mods web page excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat)
/// <param name="modUrlFormat">The URL for a Nexus Mods mod page for the user, excluding the <paramref name="baseUrl"/>, where {0} is the mod ID.</param>
/// <param name="modScrapeUrlFormat">The URL for a Nexus mod page to scrape for versions, excluding the base URL, where {0} is the mod ID.</param>
public NexusWebScrapeClient(string userAgent, string baseUrl, string modUrlFormat, string modScrapeUrlFormat)
{
this.ModUrlFormat = modUrlFormat;
this.ModScrapeUrlFormat = modScrapeUrlFormat;
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
}
@ -42,7 +50,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
try
{
html = await this.Client
.GetAsync(string.Format(this.ModUrlFormat, id))
.GetAsync(string.Format(this.ModScrapeUrlFormat, id))
.AsString();
}
catch (ApiException ex) when (ex.Status == HttpStatusCode.NotFound)
@ -75,11 +83,43 @@ namespace StardewModdingAPI.Web.Framework.Clients.Nexus
string url = this.GetModUrl(id);
string name = doc.DocumentNode.SelectSingleNode("//h1")?.InnerText.Trim();
string version = doc.DocumentNode.SelectSingleNode("//ul[contains(@class, 'stats')]//li[@class='stat-version']//div[@class='stat']")?.InnerText.Trim();
SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion);
// extract file versions
List<string> rawVersions = new List<string>();
foreach (var fileSection in doc.DocumentNode.SelectNodes("//div[contains(@class, 'files-tabs')]"))
{
string sectionName = fileSection.Descendants("h2").First().InnerText;
if (sectionName != "Main files" && sectionName != "Optional files")
continue;
rawVersions.AddRange(
from statBox in fileSection.Descendants().Where(p => p.HasClass("stat-version"))
from versionStat in statBox.Descendants().Where(p => p.HasClass("stat"))
select versionStat.InnerText.Trim()
);
}
// choose latest file version
ISemanticVersion latestFileVersion = null;
foreach (string rawVersion in rawVersions)
{
if (!SemanticVersion.TryParse(rawVersion, out ISemanticVersion cur))
continue;
if (parsedVersion != null && !cur.IsNewerThan(parsedVersion))
continue;
if (latestFileVersion != null && !cur.IsNewerThan(latestFileVersion))
continue;
latestFileVersion = cur;
}
// yield info
return new NexusMod
{
Name = name,
Version = version,
Version = parsedVersion?.ToString() ?? version,
LatestFileVersion = latestFileVersion,
Url = url
};
}

View File

@ -47,24 +47,21 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/****
** Nexus Mods
****/
/// <summary>The user agent for the Nexus Mods API client.</summary>
public string NexusUserAgent { get; set; }
/// <summary>The base URL for the Nexus Mods API.</summary>
public string NexusBaseUrl { get; set; }
/// <summary>The URL for a Nexus Mods API query excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
/// <summary>The URL for a Nexus mod page for the user, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
public string NexusModUrlFormat { get; set; }
/// <summary>The URL for a Nexus mod page to scrape for versions, excluding the <see cref="NexusBaseUrl"/>, where {0} is the mod ID.</summary>
public string NexusModScrapeUrlFormat { get; set; }
/****
** Pastebin
****/
/// <summary>The base URL for the Pastebin API.</summary>
public string PastebinBaseUrl { get; set; }
/// <summary>The user agent for the Pastebin API client, where {0} is the SMAPI version.</summary>
public string PastebinUserAgent { get; set; }
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
public string PastebinUserKey { get; set; }

View File

@ -24,5 +24,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The repository key for Nexus Mods.</summary>
public string NexusKey { get; set; }
/// <summary>The web URL for the wiki compatibility list.</summary>
public string WikiCompatibilityPageUrl { get; set; }
}
}

View File

@ -1,7 +1,7 @@
namespace StardewModdingAPI.Web.Framework.ConfigModels
{
/// <summary>The config settings for the app context.</summary>
public class ContextConfig // must be public to pass into views
/// <summary>The site config settings.</summary>
public class SiteConfig // must be public to pass into views
{
/*********
** Accessors
@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The root URL for the log parser.</summary>
public string LogParserUrl { get; set; }
/// <summary>Whether to show SMAPI beta versions on the main page, if any.</summary>
public bool EnableSmapiBeta { get; set; }
}
}

View File

@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Common;
using StardewModdingAPI.Toolkit;
using StardewModdingAPI.Web.Framework.LogParsing.Models;
namespace StardewModdingAPI.Web.Framework.LogParsing
@ -31,13 +31,13 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
/// <summary>A regex pattern matching an entry in SMAPI's mod list.</summary>
/// <remarks>The author name and description are optional.</remarks>
private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersionImpl.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching the start of SMAPI's content pack list.</summary>
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?) \| (?<description>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
@ -135,7 +135,10 @@ 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;
int lastDelimiterPos = log.ModPath.LastIndexOfAny(new char[] { '/', '\\' });
log.GamePath = lastDelimiterPos >= 0
? log.ModPath.Substring(0, lastDelimiterPos)
: log.ModPath;
}
// log UTC timestamp line

View File

@ -1,24 +1,29 @@
using StardewModdingAPI.Internal.ConsoleWriting;
namespace StardewModdingAPI.Web.Framework.LogParsing.Models
{
/// <summary>The log severity levels.</summary>
public enum LogLevel
{
/// <summary>Tracing info intended for developers.</summary>
Trace,
Trace = ConsoleLogLevel.Trace,
/// <summary>Troubleshooting info that may be relevant to the player.</summary>
Debug,
Debug = ConsoleLogLevel.Debug,
/// <summary>Info relevant to the player. This should be used judiciously.</summary>
Info,
Info = ConsoleLogLevel.Info,
/// <summary>An issue the player should be aware of. This should be used rarely.</summary>
Warn,
Warn = ConsoleLogLevel.Warn,
/// <summary>A message indicating something went wrong.</summary>
Error,
Error = ConsoleLogLevel.Error,
/// <summary>Important information to highlight for the player when player action is needed (e.g. new version available). This should be used rarely to avoid alert fatigue.</summary>
Alert
Alert = ConsoleLogLevel.Alert,
/// <summary>A critical issue that generally signals an immediate end to the application.</summary>
Critical = ConsoleLogLevel.Critical
}
}

View File

@ -1,6 +1,6 @@
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{

View File

@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
namespace StardewModdingAPI.Web.Framework.ModRepositories

View File

@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
namespace StardewModdingAPI.Web.Framework.ModRepositories
@ -38,21 +38,25 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
// fetch info
try
{
// get latest release
// get latest release (whether preview or stable)
GitRelease latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: true);
GitRelease preview = null;
if (latest == null)
return new ModInfoModel("Found no mod with this ID.");
// get latest stable release (if not latest)
// split stable/prerelease if applicable
GitRelease preview = null;
if (latest.IsPrerelease)
{
preview = latest;
latest = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false);
GitRelease result = await this.Client.GetLatestReleaseAsync(id, includePrerelease: false);
if (result != null)
{
preview = latest;
latest = result;
}
}
// return data
return new ModInfoModel(name: id, version: this.NormaliseVersion(latest?.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases");
return new ModInfoModel(name: id, version: this.NormaliseVersion(latest.Tag), previewVersion: this.NormaliseVersion(preview?.Tag), url: $"https://github.com/{id}/releases");
}
catch (Exception ex)
{

View File

@ -1,6 +1,5 @@
using System;
using System.Threading.Tasks;
using StardewModdingAPI.Common.Models;
namespace StardewModdingAPI.Web.Framework.ModRepositories
{

View File

@ -1,4 +1,4 @@
namespace StardewModdingAPI.Common.Models
namespace StardewModdingAPI.Web.Framework.ModRepositories
{
/// <summary>Generic metadata about a mod.</summary>
internal class ModInfoModel
@ -9,10 +9,10 @@ namespace StardewModdingAPI.Common.Models
/// <summary>The mod name.</summary>
public string Name { get; set; }
/// <summary>The semantic version for the mod's latest release.</summary>
/// <summary>The mod's latest release number.</summary>
public string Version { get; set; }
/// <summary>The semantic version for the mod's latest preview release, if available and different from <see cref="Version"/>.</summary>
/// <summary>The mod's latest optional release, if newer than <see cref="Version"/>.</summary>
public string PreviewVersion { get; set; }
/// <summary>The mod's web URL.</summary>
@ -43,7 +43,7 @@ namespace StardewModdingAPI.Common.Models
this.Version = version;
this.PreviewVersion = previewVersion;
this.Url = url;
this.Error = error; // mainly initialised here for the JSON deserialiser
this.Error = error;
}
/// <summary>Construct an instance.</summary>

View File

@ -1,6 +1,6 @@
using System;
using System.Threading.Tasks;
using StardewModdingAPI.Common.Models;
using StardewModdingAPI.Toolkit.Framework.Clients.WebApi;
using StardewModdingAPI.Web.Framework.Clients.Nexus;
namespace StardewModdingAPI.Web.Framework.ModRepositories
@ -43,7 +43,7 @@ namespace StardewModdingAPI.Web.Framework.ModRepositories
return new ModInfoModel("Found no mod with this ID.");
if (mod.Error != null)
return new ModInfoModel(mod.Error);
return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), url: mod.Url);
return new ModInfoModel(name: mod.Name, version: this.NormaliseVersion(mod.Version), previewVersion: mod.LatestFileVersion?.ToString(), url: mod.Url);
}
catch (Exception ex)
{

View File

@ -1,5 +1,5 @@
using Microsoft.AspNetCore.Routing.Constraints;
using StardewModdingAPI.Common;
using StardewModdingAPI.Toolkit;
namespace StardewModdingAPI.Web.Framework
{
@ -11,6 +11,6 @@ namespace StardewModdingAPI.Web.Framework
*********/
/// <summary>Construct an instance.</summary>
public VersionConstraint()
: base(SemanticVersionImpl.Regex) { }
: base(SemanticVersion.Regex) { }
}
}

View File

@ -10,18 +10,26 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.7.2" />
<PackageReference Include="Markdig" Version="0.14.9" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.0.2" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.0.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.1.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.8.4" />
<PackageReference Include="Markdig" Version="0.15.0" />
<PackageReference Include="Microsoft.AspNetCore" Version="2.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.1.1" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="2.1.1" />
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.2.0" />
</ItemGroup>
<ItemGroup>
<DotNetCliToolReference Include="Microsoft.VisualStudio.Web.CodeGeneration.Tools" Version="1.0.1" />
</ItemGroup>
<Import Project="..\SMAPI.Common\StardewModdingAPI.Common.projitems" Label="Shared" />
<Import Project="..\SMAPI.Internal\SMAPI.Internal.projitems" Label="Shared" />
<ItemGroup>
<ProjectReference Include="..\StardewModdingAPI.Toolkit\StardewModdingAPI.Toolkit.csproj" />
</ItemGroup>
<ItemGroup>
<Content Update="wwwroot\StardewModdingAPI.metadata.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@ -7,6 +7,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using StardewModdingAPI.Toolkit.Serialisation;
using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Clients.Chucklefish;
using StardewModdingAPI.Web.Framework.Clients.GitHub;
@ -49,13 +50,16 @@ namespace StardewModdingAPI.Web
// init configuration
services
.Configure<ModUpdateCheckConfig>(this.Configuration.GetSection("ModUpdateCheck"))
.Configure<ContextConfig>(this.Configuration.GetSection("Context"))
.Configure<SiteConfig>(this.Configuration.GetSection("Site"))
.Configure<RouteOptions>(options => options.ConstraintMap.Add("semanticVersion", typeof(VersionConstraint)))
.AddMemoryCache()
.AddMvc()
.ConfigureApplicationPartManager(manager => manager.FeatureProviders.Add(new InternalControllerFeatureProvider()))
.AddJsonOptions(options =>
{
foreach (JsonConverter converter in new JsonHelper().JsonSettings.Converters)
options.SerializerSettings.Converters.Add(converter);
options.SerializerSettings.Formatting = Formatting.Indented;
options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
});
@ -82,15 +86,11 @@ namespace StardewModdingAPI.Web
password: api.GitHubPassword
));
//services.AddSingleton<INexusClient>(new NexusClient(
// userAgent: api.NexusUserAgent,
// baseUrl: api.NexusBaseUrl,
// modUrlFormat: api.NexusModUrlFormat
//));
services.AddSingleton<INexusClient>(new NexusWebScrapeClient(
userAgent: userAgent,
baseUrl: api.NexusBaseUrl,
modUrlFormat: api.NexusModUrlFormat
modUrlFormat: api.NexusModUrlFormat,
modScrapeUrlFormat: api.NexusModScrapeUrlFormat
));
services.AddSingleton<IPastebinClient>(new PastebinClient(
@ -153,23 +153,23 @@ namespace StardewModdingAPI.Web
));
// shortcut redirects
redirects.Add(new RedirectToUrlRule(@"^/buildmsg(?:/?(.*))$", "https://github.com/Pathoschild/SMAPI/blob/develop/docs/mod-build-config.md#$1"));
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"));
redirects.Add(new RedirectToUrlRule(@"^/install\.?$", "https://stardewvalleywiki.com/Modding:Player_Guide/Getting_Started#Install_SMAPI"));
// redirect legacy canimod.com URLs
var wikiRedirects = new Dictionary<string, string[]>
{
["Modding:Creating_a_SMAPI_mod"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod" },
["Modding:Index#Migration_guides"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
["Modding:Modder_Guide"] = new[] { "^/for-devs/creating-a-smapi-mod", "^/guides/creating-a-smapi-mod", "^/for-devs/creating-a-smapi-mod-advanced-config" },
["Modding:Player_Guide"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods", "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" },
["Modding:Editing_XNB_files"] = new[] { "^/for-devs/creating-an-xnb-mod", "^/guides/creating-an-xnb-mod" },
["Modding:Event_data"] = new[] { "^/for-devs/events", "^/guides/events" },
["Modding:Gift_taste_data"] = new[] { "^/for-devs/npc-gift-tastes", "^/guides/npc-gift-tastes" },
["Modding:IDE_reference"] = new[] { "^/for-devs/creating-a-smapi-mod-ide-primer" },
["Modding:Installing_SMAPI"] = new[] { "^/for-players/install-smapi", "^/guides/using-mods" },
["Modding:Object_data"] = new[] { "^/for-devs/object-data", "^/guides/object-data" },
["Modding:Player_FAQs"] = new[] { "^/for-players/faqs", "^/for-players/intro", "^/for-players/use-mods", "^/guides/asking-for-help", "^/guides/smapi-faq" },
["Modding:SMAPI_APIs"] = new[] { "^/for-devs/creating-a-smapi-mod-advanced-config" },
["Modding:Updating_deprecated_SMAPI_code"] = new[] { "^/for-devs/updating-a-smapi-mod", "^/guides/updating-a-smapi-mod" },
["Modding:Weather_data"] = new[] { "^/for-devs/weather", "^/guides/weather" }
};
foreach (KeyValuePair<string, string[]> pair in wikiRedirects)

View File

@ -1,3 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using StardewModdingAPI.Web.Framework.LogParsing.Models;
namespace StardewModdingAPI.Web.ViewModels
@ -5,6 +8,13 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The view model for the log parser page.</summary>
public class LogParserModel
{
/*********
** Properties
*********/
/// <summary>A regex pattern matching characters to remove from a mod name to create the slug ID.</summary>
private readonly Regex SlugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/*********
** Accessors
*********/
@ -17,6 +27,15 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>The parsed log info.</summary>
public ParsedLog ParsedLog { get; set; }
/// <summary>Whether to show the raw unparsed log.</summary>
public bool ShowRaw { get; set; }
/// <summary>An error which occurred while uploading the log to Pastebin.</summary>
public string UploadError { get; set; }
/// <summary>An error which occurred while parsing the log file.</summary>
public string ParseError => this.ParsedLog?.Error;
/*********
** Public methods
@ -27,12 +46,46 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>Construct an instance.</summary>
/// <param name="sectionUrl">The root URL for the log parser controller.</param>
/// <param name="pasteID">The paste ID.</param>
/// <param name="parsedLog">The parsed log info.</param>
public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog)
public LogParserModel(string sectionUrl, string pasteID)
{
this.SectionUrl = sectionUrl;
this.PasteID = pasteID;
this.ParsedLog = null;
this.ShowRaw = false;
}
/// <summary>Construct an instance.</summary>
/// <param name="sectionUrl">The root URL for the log parser controller.</param>
/// <param name="pasteID">The paste ID.</param>
/// <param name="parsedLog">The parsed log info.</param>
/// <param name="showRaw">Whether to show the raw unparsed log.</param>
public LogParserModel(string sectionUrl, string pasteID, ParsedLog parsedLog, bool showRaw)
: this(sectionUrl, pasteID)
{
this.ParsedLog = parsedLog;
this.ShowRaw = showRaw;
}
/// <summary>Get all content packs in the log grouped by the mod they're for.</summary>
public IDictionary<string, LogModInfo[]> GetContentPacksByMod()
{
// get all mods & content packs
LogModInfo[] mods = this.ParsedLog?.Mods;
if (mods == null || !mods.Any())
return new Dictionary<string, LogModInfo[]>();
// group by mod
return mods
.Where(mod => mod.ContentPackFor != null)
.GroupBy(mod => mod.ContentPackFor)
.ToDictionary(group => group.Key, group => group.ToArray());
}
/// <summary>Get a sanitised mod name that's safe to use in anchors, attributes, and URLs.</summary>
/// <param name="modName">The mod name.</param>
public string GetSlug(string modName)
{
return this.SlugInvalidCharPattern.Replace(modName, "");
}
}
}

View File

@ -3,7 +3,9 @@
}
@model StardewModdingAPI.Web.ViewModels.IndexModel
@section Head {
<link rel="stylesheet" href="~/Content/css/index.css" />
<link rel="stylesheet" href="~/Content/css/index.css?r=20180615" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script>
<script src="~/Content/js/index.js?r=20180615"></script>
}
<p id="blurb">
@ -13,17 +15,29 @@
</p>
<div id="call-to-action">
<a href="@Model.StableVersion.DownloadUrl" class="main-cta">Download SMAPI @Model.StableVersion.Version</a><br />
<div class="cta-dropdown">
<a href="@Model.StableVersion.DownloadUrl" class="main-cta download">Download SMAPI @Model.StableVersion.Version</a><br/>
<div class="dropdown-content">
<a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
<a href="@Model.StableVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
</div>
</div><br />
@if (Model.BetaVersion != null)
{
<a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta">Download SMAPI @Model.BetaVersion.Version<br /><small>for Stardew Valley 1.3 beta</small></a><br />
<div class="cta-dropdown secondary-cta-dropdown">
<a href="@Model.BetaVersion.DownloadUrl" class="secondary-cta download">Download SMAPI @Model.BetaVersion.Version<br/><small>for Stardew Valley 1.3 beta</small></a><br/>
<div class="dropdown-content">
<a href="https://www.nexusmods.com/stardewvalley/mods/2400"><img src="Content/images/nexus-icon.png" /> Download from Nexus</a>
<a href="@Model.BetaVersion.DownloadUrl"><img src="Content/images/direct-download-icon.png" /> Direct download</a>
</div>
</div><br />
}
<a href="https://stardewvalleywiki.com/Modding:Installing_SMAPI" class="secondary-cta">Install guide</a><br />
<a href="https://stardewvalleywiki.com/Modding:Player_FAQs" class="secondary-cta">FAQs</a><br />
<img src="favicon.ico" />
<a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a><br />
<img id="pufferchick" src="Content/images/pufferchick.png" />
</div>
<h2>Get help</h2>
<h2 id="help">Get help</h2>
<ul>
<li><a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">Mod compatibility list</a></li>
<li>Get help <a href="https://stardewvalleywiki.com/Modding:Community#Discord">on Discord</a> or <a href="https://community.playstarbound.com/threads/smapi-stardew-modding-api.108375/">in the forums</a></li>
@ -31,7 +45,7 @@
@if (Model.BetaVersion == null)
{
<h2>What's new in SMAPI @Model.StableVersion.Version?</h2>
<h2 id="whatsnew">What's new in SMAPI @Model.StableVersion.Version?</h2>
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
</div>
@ -39,7 +53,7 @@
}
else
{
<h2>What's new in...</h2>
<h2 id="whatsnew">What's new in...</h2>
<h3>SMAPI @Model.StableVersion.Version?</h3>
<div class="github-description">
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
@ -53,7 +67,7 @@ else
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="https://stardewvalleywiki.com/Modding:SMAPI_compatibility">mod compatibility list</a> for more info.</p>
}
<h2>Donate to support SMAPI ♥</h2>
<h2 id="donate">Donate to support SMAPI ♥</h2>
<p>
SMAPI is an open-source project by Pathoschild. It will always be free, but donations
are much appreciated to help pay for development, server hosting, domain fees, coffee, etc.
@ -75,15 +89,23 @@ else
Special thanks to
acerbicon,
<a href="https://www.nexusmods.com/stardewvalley/users/31393530">ChefRude</a>,
cheesysteak,
hawkfalcon,
jwdred,
OfficialPiAddict,
KNakamura,
Kono Tyran,
Pucklynn,
Robby LaFarge,
and a few anonymous users for their ongoing support; you're awesome! 🏅
</p>
<h2>For mod creators</h2>
<h2 id="modcreators">For mod creators</h2>
<ul>
<li><a href="@Model.StableVersion.DevDownloadUrl">SMAPI @Model.StableVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li>
@if (Model.BetaVersion != null)
{
<li><a href="@Model.BetaVersion.DevDownloadUrl">SMAPI @Model.BetaVersion.Version for developers</a> (includes <a href="https://docs.microsoft.com/en-us/visualstudio/ide/using-intellisense">intellisense</a> and full console output)</li>
}
<li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li>
<li>Need help? Come <a href="https://stardewvalleywiki.com/Modding:Community#Discord">chat on Discord</a>.</li>
</ul>

View File

@ -1,76 +1,120 @@
@{
ViewData["Title"] = "SMAPI log parser";
IDictionary<string, LogModInfo[]> contentPacks = Model.ParsedLog?.Mods
?.GroupBy(mod => mod.ContentPackFor)
.Where(group => group.Key != null)
.ToDictionary(group => group.Key, group => group.ToArray());
Regex slugInvalidCharPattern = new Regex("[^a-z0-9]", RegexOptions.Compiled | RegexOptions.IgnoreCase);
string GetSlug(string modName)
{
return slugInvalidCharPattern.Replace(modName, "");
}
}
@using System.Text.RegularExpressions
@using Newtonsoft.Json
@using StardewModdingAPI.Web.Framework.LogParsing.Models
@model StardewModdingAPI.Web.ViewModels.LogParserModel
@{
ViewData["Title"] = "SMAPI log parser";
IDictionary<string, LogModInfo[]> contentPacks = Model.GetContentPacksByMod();
IDictionary<string, bool> defaultFilters = Enum
.GetValues(typeof(LogLevel))
.Cast<LogLevel>()
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
}
@section Head {
<link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180225" />
@if (Model.PasteID != null)
{
<meta name="robots" content="noindex" />
}
<link rel="stylesheet" href="~/Content/css/log-parser.css?r=20180627" />
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.2.1/jquery.min.js" crossorigin="anonymous"></script>
<script src="~/Content/js/log-parser.js?r=20180225"></script>
<script src="~/Content/js/log-parser.js?r=20180627"></script>
<script>
$(function() {
smapi.logParser({
logStarted: new Date(@Json.Serialize(Model.ParsedLog?.Timestamp)),
showPopup: @Json.Serialize(Model.ParsedLog == null),
showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), new JsonSerializerSettings { Formatting = Formatting.None }),
showLevels: {
trace: false,
debug: false,
info: true,
alert: true,
warn: true,
error: true
}
showMods: @Json.Serialize(Model.ParsedLog?.Mods?.Select(p => Model.GetSlug(p.Name)).Distinct().ToDictionary(slug => slug, slug => true), noFormatting),
showLevels: @Json.Serialize(defaultFilters, noFormatting),
enableFilters: @Json.Serialize(!Model.ShowRaw)
}, '@Model.SectionUrl');
});
</script>
}
@*********
** Intro
*********@
<p id="blurb">This page lets you upload, view, and share a SMAPI log to help troubleshoot mod issues.</p>
@if (Model.ParsedLog?.IsValid == true)
{
<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)
@* upload result banner *@
@if (Model.UploadError != null)
{
<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>
<strong>Oops, the server ran into trouble saving that file.</strong><br />
<small v-pre>Error details: @Model.UploadError</small>
</div>
}
else
else if (Model.ParseError != null)
{
<input type="button" id="upload-button" value="Share a new log" />
<div class="banner error" v-pre>
<strong>Oops, couldn't parse that log. (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 href="@Model.SectionUrl">upload a new log</a>.)<br />
<br />
<small v-pre>Error details: @Model.ParseError</small>
</div>
}
else if (Model.ParsedLog?.IsValid == true)
{
<div class="banner success" v-pre>
<strong>Share this link to let someone else see the log:</strong> <code>@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))</code><br />
(Or <a href="@Model.SectionUrl">upload a new log</a>.)
</div>
}
@*********
** Parsed log
*********@
@* upload new log *@
@if (Model.ParsedLog == null)
{
<h2>Where do I find my SMAPI log?</h2>
<div>What system do you use?</div>
<ul id="os-list">
<li><input type="radio" name="os" value="linux" id="os-linux" /> <label for="os-linux">Linux</label></li>
<li><input type="radio" name="os" value="mac" id="os-mac" /> <label for="os-mac">Mac</label></li>
<li><input type="radio" name="os" value="windows" id="os-windows" /> <label for="os-windows">Windows</label></li>
</ul>
<div data-os="linux">
On Linux:
<ol>
<li>Open the Files app.</li>
<li>Click the options menu (might be labeled <em>Go</em> or <code>⋮</code>).</li>
<li>Choose <em>Enter Location</em>.</li>
<li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li>
<li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
</ol>
</div>
<div data-os="mac">
On Mac:
<ol>
<li>Open the Finder app.</li>
<li>Click <em>Go</em> at the top, then <em>Enter Location</em>.</li>
<li>Enter this exact text: <pre>~/.config/StardewValley/ErrorLogs</pre></li>
<li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
</ol>
</div>
<div data-os="windows">
On Windows:
<ol>
<li>Press the <code>Windows</code> and <code>R</code> buttons at the same time.</li>
<li>In the 'run' box that appears, enter this exact text: <pre>%appdata%\StardewValley\ErrorLogs</pre></li>
<li>The log file is <code>SMAPI-crash.txt</code> if it exists, otherwise <code>SMAPI-latest.txt</code>.</li>
</ol>
</div>
<h2>How do I share my log?</h2>
<form action="@Model.SectionUrl" method="post">
<ol>
<li>
Drag the file onto this textbox (or paste the text in):<br />
<textarea id="input" name="input" placeholder="paste log here"></textarea>
</li>
<li>
Click this button:<br />
<input type="submit" id="submit" value="save log" />
</li>
<li>On the new page, copy the URL and send it to the person helping you.</li>
</ol>
</form>
}
@* parsed log *@
@if (Model.ParsedLog?.IsValid == true)
{
<h2>Log info</h2>
@ -95,17 +139,20 @@ else
</tr>
</table>
<br />
<table id="mods">
<table id="mods" class="@(Model.ShowRaw ? "filters-disabled" : null)">
<caption>
Installed mods:
<span class="notice txt"><i>click any mod to filter</i></span>
<span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span>
<span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span>
@if (!Model.ShowRaw)
{
<span class="notice txt"><i>click any mod to filter</i></span>
<span class="notice btn txt" v-on:click="showAllMods" v-show="stats.modsHidden > 0">show all</span>
<span class="notice btn txt" v-on:click="hideAllMods" v-show="stats.modsShown > 0 && stats.modsHidden > 0">hide all</span>
}
</caption>
@foreach (var mod in Model.ParsedLog.Mods.Where(p => p.ContentPackFor == null))
{
<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>
<tr v-on:click="toggleMod('@Model.GetSlug(mod.Name)')" class="mod-entry" v-bind:class="{ hidden: !showMods['@Model.GetSlug(mod.Name)'] }">
<td><input type="checkbox" v-bind:checked="showMods['@Model.GetSlug(mod.Name)']" v-show="anyModsHidden" /></td>
<td v-pre>
<strong>@mod.Name</strong> @mod.Version
@if (contentPacks != null && contentPacks.TryGetValue(mod.Name, out LogModInfo[] contentPackList))
@ -134,36 +181,47 @@ else
</tr>
}
</table>
<div id="filters">
Filter messages:
<span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
<span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
<span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
<span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
<span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
<span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
</div>
<table id="log">
@foreach (var message in Model.ParsedLog.Messages)
{
string levelStr = message.Level.ToString().ToLower();
@if (!Model.ShowRaw)
{
<div id="filters">
Filter messages:
<span v-bind:class="{ active: showLevels['trace'] }" v-on:click="toggleLevel('trace')">TRACE</span> |
<span v-bind:class="{ active: showLevels['debug'] }" v-on:click="toggleLevel('debug')">DEBUG</span> |
<span v-bind:class="{ active: showLevels['info'] }" v-on:click="toggleLevel('info')">INFO</span> |
<span v-bind:class="{ active: showLevels['alert'] }" v-on:click="toggleLevel('alert')">ALERT</span> |
<span v-bind:class="{ active: showLevels['warn'] }" v-on:click="toggleLevel('warn')">WARN</span> |
<span v-bind:class="{ active: showLevels['error'] }" v-on:click="toggleLevel('error')">ERROR</span>
</div>
<tr class="@levelStr mod" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')">
<td v-pre>@message.Time</td>
<td v-pre>@message.Level.ToString().ToUpper()</td>
<td v-pre data-title="@message.Mod">@message.Mod</td>
<td v-pre>@message.Text</td>
</tr>
if (message.Repeated > 0)
<table id="log">
@foreach (var message in Model.ParsedLog.Messages)
{
<tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@GetSlug(message.Mod)', '@levelStr')">
<td colspan="3"></td>
<td v-pre><i>repeats [@message.Repeated] times.</i></td>
string levelStr = message.Level.ToString().ToLower();
<tr class="@levelStr mod" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')">
<td v-pre>@message.Time</td>
<td v-pre>@message.Level.ToString().ToUpper()</td>
<td v-pre data-title="@message.Mod">@message.Mod</td>
<td v-pre>@message.Text</td>
</tr>
if (message.Repeated > 0)
{
<tr class="@levelStr mod mod-repeat" v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr')">
<td colspan="3"></td>
<td v-pre><i>repeats [@message.Repeated] times.</i></td>
</tr>
}
}
}
</table>
</table>
<small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))?raw=true">view raw log</a></small>
}
else
{
<pre v-pre>@Model.ParsedLog.RawText</pre>
<small><a href="@(new Uri(new Uri(Model.SectionUrl), Model.PasteID))">view parsed log</a></small>
}
</div>
}
else if (Model.ParsedLog?.IsValid == false)
@ -171,22 +229,3 @@ else if (Model.ParsedLog?.IsValid == false)
<h3>Raw log</h3>
<pre v-pre>@Model.ParsedLog.RawText</pre>
}
<div id="upload-area">
<div id="popup-upload" class="popup">
<h1>Upload log file</h1>
<div class="frame">
<ol>
<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>
</ol>
<textarea id="input" placeholder="Paste or drag the log here"></textarea>
<div class="buttons">
<input type="button" id="submit" value="Parse" />
<input type="button" id="cancel" value="Cancel" />
</div>
</div>
</div>
<div id="uploader"></div>
</div>

View File

@ -1,11 +1,12 @@
@using Microsoft.Extensions.Options
@using StardewModdingAPI.Web.Framework.ConfigModels
@inject IOptions<ContextConfig> ContextConfig
@inject IOptions<SiteConfig> SiteConfig
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewData["Title"] - SMAPI.io</title>
<link rel="stylesheet" href="~/Content/css/main.css" />
@RenderSection("Head", required: false)
@ -14,8 +15,8 @@
<div id="sidebar">
<h4>SMAPI</h4>
<ul>
<li><a href="@ContextConfig.Value.RootUrl">About SMAPI</a></li>
<li><a href="@ContextConfig.Value.LogParserUrl">Log parser</a></li>
<li><a href="@SiteConfig.Value.RootUrl">About SMAPI</a></li>
<li><a href="@SiteConfig.Value.LogParserUrl">Log parser</a></li>
<li><a href="https://stardewvalleywiki.com/Modding:Index">Docs</a></li>
</ul>
</div>

Some files were not shown because too many files have changed in this diff Show More