Update to SMAPI 3.2
This commit is contained in:
commit
673290ca14
|
@ -1,16 +1,12 @@
|
|||
Do you want to...
|
||||
|
||||
* **Ask for help using SMAPI?**
|
||||
Please ask in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
|
||||
create a GitHub issue.
|
||||
|
||||
* **Report a bug?**
|
||||
Please report it in [the Stardew Valley Discord or mod forums](https://smapi.io/community), don't
|
||||
create a GitHub issue unless you're sure it's a bug in the SMAPI code.
|
||||
* **Ask for help or report a bug?**
|
||||
Please see 'get help' on [the SMAPI website](https://smapi.io) instead, don't create a GitHub
|
||||
issue.
|
||||
|
||||
* **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 [on Discord or in the SMAPI discussion thread](https://smapi.io/community).
|
||||
sure it'll be accepted. Feel free to come chat [on Discord](https://smapi.io/community).
|
||||
|
||||
Documenting your code and using the same formatting conventions is appreciated, but don't worry too
|
||||
much about it. We'll fix up the code after we accept the pull request if needed.
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
GitHub issues are only used for SMAPI development tasks.
|
||||
|
||||
To get help with SMAPI problems, [ask on Discord or in the forums](https://smapi.io/community)
|
||||
instead.
|
||||
To get help with SMAPI problems, see 'get help' on [the SMAPI website](https://smapi.io/) instead.
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
<!--set properties -->
|
||||
<PropertyGroup>
|
||||
<Version>3.1.0</Version>
|
||||
<Version>3.2.0</Version>
|
||||
<Product>SMAPI</Product>
|
||||
|
||||
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
|
||||
|
@ -31,8 +31,9 @@
|
|||
<Copy SourceFiles="$(TargetDir)\SMAPI.config.json" DestinationFiles="$(GamePath)\smapi-internal\config.json" />
|
||||
<Copy SourceFiles="$(TargetDir)\SMAPI.metadata.json" DestinationFiles="$(GamePath)\smapi-internal\metadata.json" />
|
||||
<Copy SourceFiles="$(TargetDir)\0Harmony.dll" DestinationFolder="$(GamePath)\smapi-internal" />
|
||||
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)\smapi-internal" />
|
||||
<Copy SourceFiles="$(TargetDir)\Mono.Cecil.dll" DestinationFolder="$(GamePath)\smapi-internal" />
|
||||
<Copy SourceFiles="$(TargetDir)\Newtonsoft.Json.dll" DestinationFolder="$(GamePath)\smapi-internal" />
|
||||
<Copy SourceFiles="$(TargetDir)\TMXTile.dll" DestinationFolder="$(GamePath)\smapi-internal" />
|
||||
<Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(GamePath)\smapi-internal\i18n" />
|
||||
</Target>
|
||||
<Target Name="CopyDefaultMods" Condition="'$(MSBuildProjectName)' == 'SMAPI.Mods.ConsoleCommands' OR '$(MSBuildProjectName)' == 'SMAPI.Mods.SaveBackup'">
|
||||
|
|
|
@ -1,75 +1,80 @@
|
|||
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<!--
|
||||
|
||||
|
||||
This build task is run from the installer project after all projects have been compiled, and
|
||||
creates the build package in the bin\Packages folder.
|
||||
|
||||
|
||||
-->
|
||||
<Target Name="PrepareInstaller" AfterTargets="AfterBuild">
|
||||
<PropertyGroup>
|
||||
<RootPath>$(SolutionDir)\..</RootPath>
|
||||
<CompiledRootPath>$(RootPath)\bin\$(Configuration)</CompiledRootPath>
|
||||
<CompiledSmapiPath>$(CompiledRootPath)\SMAPI</CompiledSmapiPath>
|
||||
<CompiledToolkitPath>$(CompiledRootPath)\SMAPI.Toolkit\net4.5</CompiledToolkitPath>
|
||||
<CompiledModsPath>$(CompiledRootPath)\Mods</CompiledModsPath>
|
||||
<PackagePath>$(SolutionDir)\..\bin\SMAPI installer</PackagePath>
|
||||
<PackageDevPath>$(SolutionDir)\..\bin\SMAPI installer for developers</PackageDevPath>
|
||||
<PlatformName>windows</PlatformName>
|
||||
<PlatformName Condition="$(OS) != 'Windows_NT'">unix</PlatformName>
|
||||
|
||||
<BuildRootPath>$(SolutionDir)</BuildRootPath>
|
||||
<OutRootPath>$(SolutionDir)\..\bin</OutRootPath>
|
||||
|
||||
<SmapiBin>$(BuildRootPath)\SMAPI\bin\$(Configuration)</SmapiBin>
|
||||
<ToolkitBin>$(BuildRootPath)\SMAPI.Toolkit\bin\$(Configuration)\net4.5</ToolkitBin>
|
||||
<ConsoleCommandsBin>$(BuildRootPath)\SMAPI.Mods.ConsoleCommands\bin\$(Configuration)</ConsoleCommandsBin>
|
||||
<SaveBackupBin>$(BuildRootPath)\SMAPI.Mods.SaveBackup\bin\$(Configuration)</SaveBackupBin>
|
||||
|
||||
<PackagePath>$(OutRootPath)\SMAPI installer</PackagePath>
|
||||
<PackageDevPath>$(OutRootPath)\SMAPI installer for developers</PackageDevPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<TranslationFiles Include="$(CompiledSmapiPath)\i18n\*.json" />
|
||||
<TranslationFiles Include="$(SmapiBin)\i18n\*.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- reset package directory -->
|
||||
<RemoveDir Directories="$(PackagePath)" />
|
||||
<RemoveDir Directories="$(PackageDevPath)" />
|
||||
|
||||
|
||||
<!-- copy installer files -->
|
||||
<Copy SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install on Linux.sh" />
|
||||
<Copy SourceFiles="$(TargetDir)\unix-install.sh" DestinationFiles="$(PackagePath)\install on Mac.command" />
|
||||
<Copy SourceFiles="$(TargetDir)\windows-install.bat" DestinationFiles="$(PackagePath)\install on Windows.bat" />
|
||||
<Copy SourceFiles="$(TargetDir)\README.txt" DestinationFiles="$(PackagePath)\README.txt" />
|
||||
<Copy SourceFiles="$(TargetDir)\assets\unix-install.sh" DestinationFiles="$(PackagePath)\install on Linux.sh" />
|
||||
<Copy SourceFiles="$(TargetDir)\assets\unix-install.sh" DestinationFiles="$(PackagePath)\install on Mac.command" />
|
||||
<Copy SourceFiles="$(TargetDir)\assets\windows-install.bat" DestinationFiles="$(PackagePath)\install on Windows.bat" />
|
||||
<Copy SourceFiles="$(TargetDir)\assets\README.txt" DestinationFiles="$(PackagePath)\README.txt" />
|
||||
<Copy SourceFiles="$(TargetDir)\$(TargetName).exe" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe" />
|
||||
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\windows-exe-config.xml" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe.config" />
|
||||
<Copy Condition="$(PlatformName) == 'windows'" SourceFiles="$(TargetDir)\assets\windows-exe-config.xml" DestinationFiles="$(PackagePath)\internal\$(PlatformName)-install.exe.config" />
|
||||
|
||||
<!--copy bundle files-->
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.exe" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.pdb" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\StardewModdingAPI.xml" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\steam_appid.txt" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\0Harmony.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\Mono.Cecil.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\Newtonsoft.Json.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\SMAPI.config.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\config.json" />
|
||||
<Copy SourceFiles="$(CompiledSmapiPath)\SMAPI.metadata.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\metadata.json" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(CompiledToolkitPath)\SMAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.exe" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.pdb" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(SmapiBin)\StardewModdingAPI.xml" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(SmapiBin)\steam_appid.txt" DestinationFolder="$(PackagePath)\bundle" />
|
||||
<Copy SourceFiles="$(SmapiBin)\0Harmony.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(SmapiBin)\Mono.Cecil.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(SmapiBin)\Newtonsoft.Json.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(SmapiBin)\TMXTile.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(SmapiBin)\SMAPI.config.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\config.json" />
|
||||
<Copy SourceFiles="$(SmapiBin)\SMAPI.metadata.json" DestinationFiles="$(PackagePath)\bundle\smapi-internal\metadata.json" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.pdb" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="$(ToolkitBin)\SMAPI.Toolkit.CoreInterfaces.xml" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy SourceFiles="@(TranslationFiles)" DestinationFolder="$(PackagePath)\bundle\smapi-internal\i18n" />
|
||||
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(TargetDir)\unix-launcher.sh" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI" />
|
||||
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(CompiledSmapiPath)\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(OS) == 'Windows_NT'" SourceFiles="$(TargetDir)\windows-exe-config.xml" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI.exe.config" />
|
||||
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\unix-launcher.sh" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI" />
|
||||
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(SmapiBin)\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(SmapiBin)\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(PlatformName) == 'windows'" SourceFiles="$(TargetDir)\assets\windows-exe-config.xml" DestinationFiles="$(PackagePath)\bundle\StardewModdingAPI.exe.config" />
|
||||
|
||||
<!--copy bundled mods-->
|
||||
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\ConsoleCommands.dll" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\ConsoleCommands.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(CompiledModsPath)\ConsoleCommands\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\SaveBackup.dll" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\SaveBackup.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
<Copy SourceFiles="$(CompiledModsPath)\SaveBackup\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
<Copy SourceFiles="$(ConsoleCommandsBin)\ConsoleCommands.dll" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(ConsoleCommandsBin)\ConsoleCommands.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(ConsoleCommandsBin)\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\ConsoleCommands" />
|
||||
<Copy SourceFiles="$(SaveBackupBin)\SaveBackup.dll" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
<Copy SourceFiles="$(SaveBackupBin)\SaveBackup.pdb" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
<Copy SourceFiles="$(SaveBackupBin)\manifest.json" DestinationFolder="$(PackagePath)\bundle\Mods\SaveBackup" />
|
||||
|
||||
<!-- fix errors on Linux/Mac (sample: https://smapi.io/log/mMdFUpgB) -->
|
||||
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(OS) != 'Windows_NT'" SourceFiles="$(RootPath)\build\lib\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\System.Numerics.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
<Copy Condition="$(PlatformName) == 'unix'" SourceFiles="$(TargetDir)\assets\System.Runtime.Caching.dll" DestinationFolder="$(PackagePath)\bundle\smapi-internal" />
|
||||
|
||||
<!-- fix Linux/Mac permissions -->
|
||||
<Exec Condition="$(OS) != 'Windows_NT'" Command="chmod 755 "$(PackagePath)\install on Linux.sh"" />
|
||||
<Exec Condition="$(OS) != 'Windows_NT'" Command="chmod 755 "$(PackagePath)\install on Mac.command"" />
|
||||
<Exec Condition="$(PlatformName) == 'unix'" Command="chmod 755 "$(PackagePath)\install on Linux.sh"" />
|
||||
<Exec Condition="$(PlatformName) == 'unix'" Command="chmod 755 "$(PackagePath)\install on Mac.command"" />
|
||||
|
||||
<!-- finalise 'for developers' installer -->
|
||||
<ItemGroup>
|
||||
|
|
|
@ -64,7 +64,7 @@ locale | status
|
|||
---------- | :----------------
|
||||
default | ✓ [fully translated](../src/SMAPI/i18n/default.json)
|
||||
Chinese | ✓ [fully translated](../src/SMAPI/i18n/zh.json)
|
||||
French | ❑ not translated
|
||||
French | ✓ [fully translated](../src/SMAPI/i18n/fr.json)
|
||||
German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
|
||||
Hungarian | ❑ not translated
|
||||
Italian | ❑ not translated
|
||||
|
|
|
@ -1,8 +1,49 @@
|
|||
← [README](README.md)
|
||||
|
||||
# Release notes
|
||||
## 3.2
|
||||
Released 01 February 2020 for Stardew Valley 1.4.1 or later.
|
||||
|
||||
* For players:
|
||||
* SMAPI now prevents crashes due to invalid schedule data.
|
||||
* SMAPI now prevents crashes due to invalid building types.
|
||||
* Added support for persistent `smapi-internal/config.json` overrides (see info in the file).
|
||||
* Updated minimum game version (1.4 → 1.4.1).
|
||||
* Fixed 'collection was modified' error when returning to title in rare cases.
|
||||
* Fixed error when update-checking a mod with a Chucklefish page that has no version.
|
||||
* Fixed rare error when building/demolishing buildings.
|
||||
* Fixed SMAPI beta versions not showing update alert on next launch (thanks to danvolchek!).
|
||||
|
||||
* For the Console Commands mod:
|
||||
* Added `performance` command to track mod performance metrics. This is an advanced experimental feature. (Thanks to Drachenkätzchen!)
|
||||
* Added `test_input` command to view button codes in the console.
|
||||
|
||||
* For the Save Backup mod:
|
||||
* Fixed extra files under `Saves` (e.g. manual backups) not being ignored.
|
||||
* Fixed Android issue where game files were backed up.
|
||||
|
||||
* For modders:
|
||||
* Added support for `.tmx` map files. (Thanks to [Platonymous for the underlying library](https://github.com/Platonymous/TMXTile)!)
|
||||
* Added special handling for `Vector2` values in `.json` files, so they work consistently crossplatform.
|
||||
* Reworked the order that asset editors/loaders are called between multiple mods to support some framework mods like Content Patcher and Json Assets. Note that the order is undefined and should not be depended on.
|
||||
* Fixed incorrect warning about mods adding invalid schedules in some cases. The validation was unreliable, and has been removed.
|
||||
* Fixed asset propagation not updating other players' sprites.
|
||||
* Fixed asset propagation for player sprites not updating recolor maps (e.g. sleeves).
|
||||
* Fixed asset propagation for marriage dialogue.
|
||||
* Fixed dialogue asset changes not correctly propagated until the next day.
|
||||
* Fixed `helper.Data.Read`/`WriteGlobalData` using the `Saves` folder instead of the game's appdata folder. The installer will move existing folders automatically.
|
||||
* Fixed issue where a mod which implemented `IAssetEditor`/`IAssetLoader` on its entry class could then remove itself from the editor/loader list.
|
||||
|
||||
* For SMAPI/tool developers:
|
||||
* Added internal performance monitoring (thanks to Drachenkätzchen!). This is disabled by default in the current version, but can be enabled using the `performance` console command.
|
||||
* Added internal support for four-part versions to support SMAPI on Android.
|
||||
* Rewrote `SemanticVersion` parsing.
|
||||
* Updated links for the new r/SMAPI subreddit.
|
||||
* The `/mods` web API endpoint now includes version mappings from the wiki.
|
||||
* Dropped API support for the pre-3.0 update-check format.
|
||||
|
||||
## 3.1
|
||||
Released 05 January 2019 for Stardew Valley 1.4 or later.
|
||||
Released 05 January 2019 for Stardew Valley 1.4.1 or later.
|
||||
|
||||
* For players:
|
||||
* Added separate group in 'skipped mods' list for broken dependencies, so it's easier to see what to fix first.
|
||||
|
@ -12,13 +53,14 @@ Released 05 January 2019 for Stardew Valley 1.4 or later.
|
|||
* Fixed compatibility with Linux Mint 18 (thanks to techge!), Arch Linux, and Linux systems with libhybris-utils installed.
|
||||
* Fixed memory leak when repeatedly loading a save and returning to title.
|
||||
* Fixed memory leak when mods reload assets.
|
||||
* Fixes for Console Commands mod:
|
||||
* added new clothing items;
|
||||
* fixed spawning new flooring and rings (thanks to Mizzion!);
|
||||
* fixed spawning custom rings added by mods;
|
||||
* Fixed errors when some item data is invalid.
|
||||
* Updated translations. Thanks to L30Bola (added Portuguese), PlussRolf (added Spanish), and shirutan (added Japanese)!
|
||||
|
||||
* For the Console Commands mod:
|
||||
* Added new clothing items.
|
||||
* Fixed spawning new flooring and rings (thanks to Mizzion!).
|
||||
* Fixed spawning custom rings added by mods.
|
||||
* Fixed errors when some item data is invalid.
|
||||
|
||||
* For the web UI:
|
||||
* Added option to edit & reupload in the JSON validator.
|
||||
* File uploads are now stored in Azure storage instead of Pastebin, due to ongoing Pastebin perfomance issues.
|
||||
|
|
|
@ -287,7 +287,11 @@ That will create a `Pathoschild.Stardew.ModBuildConfig-<version>.nupkg` file in
|
|||
which can be uploaded to NuGet or referenced directly.
|
||||
|
||||
## Release notes
|
||||
### Upcoming release
|
||||
### 3.1
|
||||
* Added support for semantic versioning 2.0.
|
||||
* `0Harmony.dll` is now ignored if the mod references Harmony directly (it's bundled with SMAPI).
|
||||
|
||||
### 3.0
|
||||
* Updated for SMAPI 3.0 and Stardew Valley 1.4.
|
||||
* Added automatic support for `assets` folders.
|
||||
* Added `$(GameExecutableName)` MSBuild variable.
|
||||
|
@ -298,6 +302,7 @@ which can be uploaded to NuGet or referenced directly.
|
|||
* Fixed `Newtonsoft.Json.pdb` included in release zips when Json.NET is referenced directly.
|
||||
* Fixed `<IgnoreModFilePatterns>` not working for `i18n` files.
|
||||
* Dropped support for older versions of SMAPI and Visual Studio.
|
||||
* Migrated package icon to NuGet's new format.
|
||||
|
||||
### 2.2
|
||||
* Added support for SMAPI 2.8+ (still compatible with earlier versions).
|
||||
|
|
|
@ -19,17 +19,8 @@ This document is about SMAPI itself; see also [mod build package](mod-package.md
|
|||
|
||||
## Customisation
|
||||
### Configuration file
|
||||
You can customise the SMAPI behaviour by editing the `smapi-internal/config.json` file in your game
|
||||
folder.
|
||||
|
||||
Basic fields:
|
||||
|
||||
field | purpose
|
||||
----------------- | -------
|
||||
`DeveloperMode` | Default `false` (except in _SMAPI for developers_ releases). Whether to enable features intended for mod developers (mainly more detailed console logging).
|
||||
`CheckForUpdates` | Default `true`. Whether SMAPI should check for a newer version when you load the game. If a new version is available, a small message will appear in the console. This doesn't affect the load time even if your connection is offline or slow, because it happens in the background.
|
||||
`VerboseLogging` | Default `false`. Whether SMAPI should log more information about the game context.
|
||||
`ModData` | Internal metadata about SMAPI mods. Changing this isn't recommended and may destabilise your game. See documentation in the file.
|
||||
You can customise some SMAPI behaviour by editing the `smapi-internal/config.json` file in your
|
||||
game folder. See documentation in the file for more info.
|
||||
|
||||
### Command-line arguments
|
||||
The SMAPI installer recognises three command-line arguments:
|
||||
|
|
|
@ -8,6 +8,9 @@ namespace StardewModdingAPI.Installer.Framework
|
|||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/****
|
||||
** Main folders
|
||||
****/
|
||||
/// <summary>The directory path containing the files to copy into the game folder.</summary>
|
||||
public DirectoryInfo BundleDir { get; }
|
||||
|
||||
|
@ -17,9 +20,18 @@ namespace StardewModdingAPI.Installer.Framework
|
|||
/// <summary>The directory into which to install mods.</summary>
|
||||
public DirectoryInfo ModsDir { get; }
|
||||
|
||||
/****
|
||||
** Installer paths
|
||||
****/
|
||||
/// <summary>The full path to directory path containing the files to copy into the game folder.</summary>
|
||||
public string BundlePath => this.BundleDir.FullName;
|
||||
|
||||
/// <summary>The full path to the backup API user settings folder, if applicable.</summary>
|
||||
public string BundleApiUserConfigPath { get; }
|
||||
|
||||
/****
|
||||
** Game paths
|
||||
****/
|
||||
/// <summary>The full path to the directory containing the installed game.</summary>
|
||||
public string GamePath => this.GameDir.FullName;
|
||||
|
||||
|
@ -29,6 +41,9 @@ namespace StardewModdingAPI.Installer.Framework
|
|||
/// <summary>The full path to SMAPI's internal configuration file.</summary>
|
||||
public string ApiConfigPath { get; }
|
||||
|
||||
/// <summary>The full path to the user's config overrides file.</summary>
|
||||
public string ApiUserConfigPath { get; }
|
||||
|
||||
/// <summary>The full path to the installed SMAPI executable file.</summary>
|
||||
public string ExecutablePath { get; }
|
||||
|
||||
|
@ -55,11 +70,14 @@ namespace StardewModdingAPI.Installer.Framework
|
|||
this.GameDir = gameDir;
|
||||
this.ModsDir = new DirectoryInfo(Path.Combine(gameDir.FullName, "Mods"));
|
||||
|
||||
this.BundleApiUserConfigPath = Path.Combine(bundleDir.FullName, "smapi-internal", "config.user.json");
|
||||
|
||||
this.ExecutablePath = Path.Combine(gameDir.FullName, gameExecutableName);
|
||||
this.UnixLauncherPath = Path.Combine(gameDir.FullName, "StardewValley");
|
||||
this.UnixSmapiLauncherPath = Path.Combine(gameDir.FullName, "StardewModdingAPI");
|
||||
this.UnixBackupLauncherPath = Path.Combine(gameDir.FullName, "StardewValley-original");
|
||||
this.ApiConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.json");
|
||||
this.ApiUserConfigPath = Path.Combine(gameDir.FullName, "smapi-internal", "config.user.json");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -351,6 +351,12 @@ namespace StardewModdingApi.Installer
|
|||
this.PrintDebug("----------------------------------------------------------------------------");
|
||||
Console.WriteLine();
|
||||
|
||||
/****
|
||||
** Back up user settings
|
||||
****/
|
||||
if (File.Exists(paths.ApiUserConfigPath))
|
||||
File.Copy(paths.ApiUserConfigPath, paths.BundleApiUserConfigPath);
|
||||
|
||||
/****
|
||||
** Always uninstall old files
|
||||
****/
|
||||
|
@ -373,6 +379,21 @@ namespace StardewModdingApi.Installer
|
|||
this.InteractivelyDelete(path);
|
||||
}
|
||||
|
||||
// move global save data folder (changed in 3.2)
|
||||
{
|
||||
string dataPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "StardewValley");
|
||||
DirectoryInfo oldDir = new DirectoryInfo(Path.Combine(dataPath, "Saves", ".smapi"));
|
||||
DirectoryInfo newDir = new DirectoryInfo(Path.Combine(dataPath, ".smapi"));
|
||||
|
||||
if (oldDir.Exists)
|
||||
{
|
||||
if (newDir.Exists)
|
||||
this.InteractivelyDelete(oldDir.FullName);
|
||||
else
|
||||
oldDir.MoveTo(newDir.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
/****
|
||||
** Install new files
|
||||
****/
|
||||
|
|
|
@ -8,7 +8,6 @@
|
|||
<LangVersion>latest</LangVersion>
|
||||
<OutputType>Exe</OutputType>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Installer</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
|
@ -17,19 +16,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="README.txt">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="windows-exe-config.xml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="windows-install.bat">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="unix-install.sh">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Include="unix-launcher.sh">
|
||||
<None Update="assets\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
|
|
@ -9,7 +9,10 @@
|
|||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="2.10.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.4.0" />
|
||||
<PackageReference Include="NUnit" Version="3.12.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.15.1" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="3.16.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -156,6 +156,9 @@ namespace StardewModdingAPI.ModBuildConfig.Framework
|
|||
// release zips
|
||||
this.EqualsInvariant(file.Extension, ".zip")
|
||||
|
||||
// Harmony (bundled into SMAPI)
|
||||
|| this.EqualsInvariant(file.Name, "0Harmony.dll")
|
||||
|
||||
// Json.NET (bundled into SMAPI)
|
||||
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.dll")
|
||||
|| this.EqualsInvariant(file.Name, "Newtonsoft.Json.pdb")
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<package xmlns="http://schemas.microsoft.com/packaging/2013/05/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>Pathoschild.Stardew.ModBuildConfig</id>
|
||||
<version>3.0.0</version>
|
||||
<version>3.1.0</version>
|
||||
<title>Build package for SMAPI mods</title>
|
||||
<authors>Pathoschild</authors>
|
||||
<owners>Pathoschild</owners>
|
||||
|
@ -14,18 +14,9 @@
|
|||
<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. For SMAPI 3.0 or later.</description>
|
||||
<releaseNotes>
|
||||
3.0.0:
|
||||
- Updated for SMAPI 3.0 and Stardew Valley 1.4.
|
||||
- Added automatic support for 'assets' folders.
|
||||
- Added $(GameExecutableName) MSBuild variable.
|
||||
- Added support for projects using the simplified .csproj format.
|
||||
- Added option to disable game debugging config.
|
||||
- Added .pdb files to builds by default (to enable line numbers in error stack traces).
|
||||
- Added optional Harmony reference.
|
||||
- Fixed Newtonsoft.Json.pdb included in release zips when Json.NET is referenced directly.
|
||||
- Fixed <IgnoreModFilePatterns> not working for i18n files.
|
||||
- Dropped support for older versions of SMAPI and Visual Studio.
|
||||
- Migrated package icon to NuGet's new format.
|
||||
3.1.0:
|
||||
- Added support for semantic versioning 2.0.
|
||||
- 0Harmony.dll is now ignored if the mod references it directly (it's bundled with SMAPI).
|
||||
</releaseNotes>
|
||||
</metadata>
|
||||
<files>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
||||
|
@ -31,13 +32,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
/// <param name="index">The zero-based index of the element to get.</param>
|
||||
public string this[int index] => this.Args[index];
|
||||
|
||||
/// <summary>A method which parses a string argument into the given value.</summary>
|
||||
/// <typeparam name="T">The expected argument type.</typeparam>
|
||||
/// <param name="input">The argument to parse.</param>
|
||||
/// <param name="output">The parsed value.</param>
|
||||
/// <returns>Returns whether the argument was successfully parsed.</returns>
|
||||
public delegate bool ParseDelegate<T>(string input, out T output);
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
|
@ -113,6 +107,38 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Try to read a decimal argument.</summary>
|
||||
/// <param name="index">The argument index.</param>
|
||||
/// <param name="name">The argument name for error messages.</param>
|
||||
/// <param name="value">The parsed value.</param>
|
||||
/// <param name="required">Whether to show an error if the argument is missing.</param>
|
||||
/// <param name="min">The minimum value allowed.</param>
|
||||
/// <param name="max">The maximum value allowed.</param>
|
||||
public bool TryGetDecimal(int index, string name, out decimal value, bool required = true, decimal? min = null, decimal? max = null)
|
||||
{
|
||||
value = 0;
|
||||
|
||||
// get argument
|
||||
if (!this.TryGet(index, name, out string raw, required))
|
||||
return false;
|
||||
|
||||
// parse
|
||||
if (!decimal.TryParse(raw, NumberStyles.Number, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
this.LogDecimalFormatError(index, name, min, max);
|
||||
return false;
|
||||
}
|
||||
|
||||
// validate
|
||||
if ((min.HasValue && value < min) || (max.HasValue && value > max))
|
||||
{
|
||||
this.LogDecimalFormatError(index, name, min, max);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Returns an enumerator that iterates through the collection.</summary>
|
||||
/// <returns>An enumerator that can be used to iterate through the collection.</returns>
|
||||
public IEnumerator<string> GetEnumerator()
|
||||
|
@ -154,5 +180,22 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
else
|
||||
this.LogError($"Argument {index} ({name}) must be an integer.");
|
||||
}
|
||||
|
||||
/// <summary>Print an error for an invalid decimal argument.</summary>
|
||||
/// <param name="index">The argument index.</param>
|
||||
/// <param name="name">The argument name for error messages.</param>
|
||||
/// <param name="min">The minimum value allowed.</param>
|
||||
/// <param name="max">The maximum value allowed.</param>
|
||||
private void LogDecimalFormatError(int index, string name, decimal? min, decimal? max)
|
||||
{
|
||||
if (min.HasValue && max.HasValue)
|
||||
this.LogError($"Argument {index} ({name}) must be a decimal between {min} and {max}.");
|
||||
else if (min.HasValue)
|
||||
this.LogError($"Argument {index} ({name}) must be a decimal and at least {min}.");
|
||||
else if (max.HasValue)
|
||||
this.LogError($"Argument {index} ({name}) must be a decimal and at most {max}.");
|
||||
else
|
||||
this.LogError($"Argument {index} ({name}) must be a decimal.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,8 +12,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
/// <summary>The command description.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
bool NeedsUpdate { get; }
|
||||
/// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary>
|
||||
bool MayNeedUpdate { get; }
|
||||
|
||||
/// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary>
|
||||
bool MayNeedInput { get; }
|
||||
|
||||
|
||||
/*********
|
||||
|
@ -27,6 +30,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
void Update(IMonitor monitor);
|
||||
void OnUpdated(IMonitor monitor);
|
||||
|
||||
/// <summary>Perform any logic when input is received.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="button">The button that was pressed.</param>
|
||||
void OnButtonPressed(IMonitor monitor, SButton button);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,647 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StardewModdingAPI.Framework;
|
||||
using StardewModdingAPI.Framework.PerformanceMonitoring;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
|
||||
{
|
||||
/// <summary>A set of commands which displays or configures performance monitoring.</summary>
|
||||
internal class PerformanceCounterCommand : TrainerCommand
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The name of the command.</summary>
|
||||
private const string CommandName = "performance";
|
||||
|
||||
/// <summary>The available commands.</summary>
|
||||
private enum SubCommand
|
||||
{
|
||||
Summary,
|
||||
Detail,
|
||||
Reset,
|
||||
Trigger,
|
||||
Enable,
|
||||
Disable,
|
||||
Help
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public PerformanceCounterCommand()
|
||||
: base(CommandName, PerformanceCounterCommand.GetDescription()) { }
|
||||
|
||||
/// <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 args
|
||||
SubCommand subcommand = SubCommand.Summary;
|
||||
{
|
||||
if (args.TryGet(0, "command", out string subcommandStr, false) && !Enum.TryParse(subcommandStr, ignoreCase: true, out subcommand))
|
||||
{
|
||||
this.LogUsageError(monitor, $"Unknown command {subcommandStr}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// handle
|
||||
switch (subcommand)
|
||||
{
|
||||
case SubCommand.Summary:
|
||||
this.HandleSummarySubCommand(monitor, args);
|
||||
break;
|
||||
|
||||
case SubCommand.Detail:
|
||||
this.HandleDetailSubCommand(monitor, args);
|
||||
break;
|
||||
|
||||
case SubCommand.Reset:
|
||||
this.HandleResetSubCommand(monitor, args);
|
||||
break;
|
||||
|
||||
case SubCommand.Trigger:
|
||||
this.HandleTriggerSubCommand(monitor, args);
|
||||
break;
|
||||
|
||||
case SubCommand.Enable:
|
||||
SCore.PerformanceMonitor.EnableTracking = true;
|
||||
monitor.Log("Performance counter tracking is now enabled", LogLevel.Info);
|
||||
break;
|
||||
|
||||
case SubCommand.Disable:
|
||||
SCore.PerformanceMonitor.EnableTracking = false;
|
||||
monitor.Log("Performance counter tracking is now disabled", LogLevel.Info);
|
||||
break;
|
||||
|
||||
case SubCommand.Help:
|
||||
this.OutputHelp(monitor, args.TryGet(1, "command", out _) ? subcommand : null as SubCommand?);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.LogUsageError(monitor, $"Unknown command {subcommand}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Handles the summary sub command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="args">The command arguments.</param>
|
||||
private void HandleSummarySubCommand(IMonitor monitor, ArgumentParser args)
|
||||
{
|
||||
if (!this.AssertEnabled(monitor))
|
||||
return;
|
||||
|
||||
IEnumerable<PerformanceCounterCollection> data = SCore.PerformanceMonitor.GetCollections();
|
||||
|
||||
double? threshold = null;
|
||||
if (args.TryGetDecimal(1, "threshold", out decimal t, required: false))
|
||||
threshold = (double?)t;
|
||||
|
||||
TimeSpan interval = TimeSpan.FromSeconds(60);
|
||||
|
||||
StringBuilder report = new StringBuilder();
|
||||
report.AppendLine($"Summary over the last {interval.TotalSeconds} seconds:");
|
||||
report.AppendLine(this.GetTableString(
|
||||
data: data,
|
||||
header: new[] { "Collection", "Avg Calls/s", "Avg Exec Time (Game)", "Avg Exec Time (Mods)", "Avg Exec Time (Game+Mods)", "Peak Exec Time" },
|
||||
getRow: item => new[]
|
||||
{
|
||||
item.Name,
|
||||
item.GetAverageCallsPerSecond().ToString(),
|
||||
this.FormatMilliseconds(item.GetGameAverageExecutionTime(interval), threshold),
|
||||
this.FormatMilliseconds(item.GetModsAverageExecutionTime(interval), threshold),
|
||||
this.FormatMilliseconds(item.GetAverageExecutionTime(interval), threshold),
|
||||
this.FormatMilliseconds(item.GetPeakExecutionTime(interval), threshold)
|
||||
},
|
||||
true
|
||||
));
|
||||
|
||||
monitor.Log(report.ToString(), LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Handles the detail sub command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="args">The command arguments.</param>
|
||||
private void HandleDetailSubCommand(IMonitor monitor, ArgumentParser args)
|
||||
{
|
||||
if (!this.AssertEnabled(monitor))
|
||||
return;
|
||||
|
||||
// parse args
|
||||
double thresholdMilliseconds = 0;
|
||||
if (args.TryGetDecimal(1, "threshold", out decimal t, required: false))
|
||||
thresholdMilliseconds = (double)t;
|
||||
|
||||
// get collections
|
||||
var collections = SCore.PerformanceMonitor.GetCollections();
|
||||
|
||||
// render
|
||||
TimeSpan averageInterval = TimeSpan.FromSeconds(60);
|
||||
StringBuilder report = new StringBuilder($"Showing details for performance counters of {thresholdMilliseconds}+ milliseconds:\n\n");
|
||||
bool anyShown = false;
|
||||
foreach (PerformanceCounterCollection collection in collections)
|
||||
{
|
||||
KeyValuePair<string, PerformanceCounter>[] data = collection.PerformanceCounters
|
||||
.Where(p => p.Value.GetAverage(averageInterval) >= thresholdMilliseconds)
|
||||
.ToArray();
|
||||
|
||||
if (data.Any())
|
||||
{
|
||||
anyShown = true;
|
||||
report.AppendLine($"{collection.Name}:");
|
||||
report.AppendLine(this.GetTableString(
|
||||
data: data,
|
||||
header: new[] { "Mod", $"Avg Exec Time (last {(int)averageInterval.TotalSeconds}s)", "Last Exec Time", "Peak Exec Time", $"Peak Exec Time (last {(int)averageInterval.TotalSeconds}s)" },
|
||||
getRow: item => new[]
|
||||
{
|
||||
item.Key,
|
||||
this.FormatMilliseconds(item.Value.GetAverage(averageInterval), thresholdMilliseconds),
|
||||
this.FormatMilliseconds(item.Value.GetLastEntry()?.ElapsedMilliseconds),
|
||||
this.FormatMilliseconds(item.Value.GetPeak()?.ElapsedMilliseconds),
|
||||
this.FormatMilliseconds(item.Value.GetPeak(averageInterval)?.ElapsedMilliseconds)
|
||||
},
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (!anyShown)
|
||||
report.AppendLine("No performance counters found.");
|
||||
|
||||
monitor.Log(report.ToString(), LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Handles the trigger sub command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="args">The command arguments.</param>
|
||||
private void HandleTriggerSubCommand(IMonitor monitor, ArgumentParser args)
|
||||
{
|
||||
if (!this.AssertEnabled(monitor))
|
||||
return;
|
||||
|
||||
if (args.TryGet(1, "mode", out string mode, false))
|
||||
{
|
||||
switch (mode)
|
||||
{
|
||||
case "list":
|
||||
this.OutputAlertTriggers(monitor);
|
||||
break;
|
||||
|
||||
case "collection":
|
||||
if (args.TryGet(2, "name", out string collectionName))
|
||||
{
|
||||
if (args.TryGetDecimal(3, "threshold", out decimal threshold))
|
||||
{
|
||||
if (!args.TryGet(4, "source", out string source, required: false))
|
||||
source = null;
|
||||
this.ConfigureAlertTrigger(monitor, collectionName, source, threshold);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "pause":
|
||||
SCore.PerformanceMonitor.PauseAlerts = true;
|
||||
monitor.Log("Alerts are now paused.", LogLevel.Info);
|
||||
break;
|
||||
|
||||
case "resume":
|
||||
SCore.PerformanceMonitor.PauseAlerts = false;
|
||||
monitor.Log("Alerts are now resumed.", LogLevel.Info);
|
||||
break;
|
||||
|
||||
case "dump":
|
||||
this.OutputAlertTriggers(monitor, true);
|
||||
break;
|
||||
|
||||
case "clear":
|
||||
this.ClearAlertTriggers(monitor);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.LogUsageError(monitor, $"Unknown mode {mode}. See '{CommandName} help trigger' for usage.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
this.OutputAlertTriggers(monitor);
|
||||
}
|
||||
|
||||
/// <summary>Sets up an an alert trigger.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="collectionName">The name of the collection.</param>
|
||||
/// <param name="sourceName">The name of the source, or null for all sources.</param>
|
||||
/// <param name="threshold">The trigger threshold, or 0 to remove.</param>
|
||||
private void ConfigureAlertTrigger(IMonitor monitor, string collectionName, string sourceName, decimal threshold)
|
||||
{
|
||||
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
|
||||
{
|
||||
if (collection.Name.ToLowerInvariant().Equals(collectionName.ToLowerInvariant()))
|
||||
{
|
||||
if (sourceName == null)
|
||||
{
|
||||
if (threshold != 0)
|
||||
{
|
||||
collection.EnableAlerts = true;
|
||||
collection.AlertThresholdMilliseconds = (double)threshold;
|
||||
monitor.Log($"Set up alert triggering for '{collectionName}' with '{this.FormatMilliseconds((double?)threshold)}'", LogLevel.Info);
|
||||
}
|
||||
else
|
||||
{
|
||||
collection.EnableAlerts = false;
|
||||
monitor.Log($"Cleared alert triggering for '{collection}'.");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var performanceCounter in collection.PerformanceCounters)
|
||||
{
|
||||
if (performanceCounter.Value.Source.ToLowerInvariant().Equals(sourceName.ToLowerInvariant()))
|
||||
{
|
||||
if (threshold != 0)
|
||||
{
|
||||
performanceCounter.Value.EnableAlerts = true;
|
||||
performanceCounter.Value.AlertThresholdMilliseconds = (double)threshold;
|
||||
monitor.Log($"Set up alert triggering for '{sourceName}' in collection '{collectionName}' with '{this.FormatMilliseconds((double?)threshold)}", LogLevel.Info);
|
||||
}
|
||||
else
|
||||
performanceCounter.Value.EnableAlerts = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
monitor.Log($"Could not find the source '{sourceName}' in collection '{collectionName}'", LogLevel.Warn);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monitor.Log($"Could not find the collection '{collectionName}'", LogLevel.Warn);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Clears alert triggering for all collections.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
private void ClearAlertTriggers(IMonitor monitor)
|
||||
{
|
||||
int clearedTriggers = 0;
|
||||
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
|
||||
{
|
||||
if (collection.EnableAlerts)
|
||||
{
|
||||
collection.EnableAlerts = false;
|
||||
clearedTriggers++;
|
||||
}
|
||||
|
||||
foreach (var performanceCounter in collection.PerformanceCounters)
|
||||
{
|
||||
if (performanceCounter.Value.EnableAlerts)
|
||||
{
|
||||
performanceCounter.Value.EnableAlerts = false;
|
||||
clearedTriggers++;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
monitor.Log($"Cleared {clearedTriggers} alert triggers.", LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Lists all configured alert triggers.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="asDump">True to dump the triggers as commands.</param>
|
||||
private void OutputAlertTriggers(IMonitor monitor, bool asDump = false)
|
||||
{
|
||||
StringBuilder report = new StringBuilder();
|
||||
report.AppendLine("Configured triggers:");
|
||||
report.AppendLine();
|
||||
var collectionTriggers = new List<CollectionTrigger>();
|
||||
var sourceTriggers = new List<SourceTrigger>();
|
||||
|
||||
foreach (PerformanceCounterCollection collection in SCore.PerformanceMonitor.GetCollections())
|
||||
{
|
||||
if (collection.EnableAlerts)
|
||||
collectionTriggers.Add(new CollectionTrigger(collection.Name, collection.AlertThresholdMilliseconds));
|
||||
|
||||
sourceTriggers.AddRange(
|
||||
from counter in collection.PerformanceCounters
|
||||
where counter.Value.EnableAlerts
|
||||
select new SourceTrigger(collection.Name, counter.Value.Source, counter.Value.AlertThresholdMilliseconds)
|
||||
);
|
||||
}
|
||||
|
||||
if (collectionTriggers.Count > 0)
|
||||
{
|
||||
report.AppendLine("Collection Triggers:");
|
||||
report.AppendLine();
|
||||
|
||||
if (asDump)
|
||||
{
|
||||
foreach (var item in collectionTriggers)
|
||||
report.AppendLine($"{CommandName} trigger {item.CollectionName} {item.Threshold}");
|
||||
}
|
||||
else
|
||||
{
|
||||
report.AppendLine(this.GetTableString(
|
||||
data: collectionTriggers,
|
||||
header: new[] { "Collection", "Threshold" },
|
||||
getRow: item => new[] { item.CollectionName, this.FormatMilliseconds(item.Threshold) },
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
report.AppendLine();
|
||||
}
|
||||
else
|
||||
report.AppendLine("No collection triggers.");
|
||||
|
||||
if (sourceTriggers.Count > 0)
|
||||
{
|
||||
report.AppendLine("Source Triggers:");
|
||||
report.AppendLine();
|
||||
|
||||
if (asDump)
|
||||
{
|
||||
foreach (SourceTrigger item in sourceTriggers)
|
||||
report.AppendLine($"{CommandName} trigger {item.CollectionName} {item.Threshold} {item.SourceName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
report.AppendLine(this.GetTableString(
|
||||
data: sourceTriggers,
|
||||
header: new[] { "Collection", "Source", "Threshold" },
|
||||
getRow: item => new[] { item.CollectionName, item.SourceName, this.FormatMilliseconds(item.Threshold) },
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
report.AppendLine();
|
||||
}
|
||||
else
|
||||
report.AppendLine("No source triggers.");
|
||||
|
||||
monitor.Log(report.ToString(), LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Handles the reset sub command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="args">The command arguments.</param>
|
||||
private void HandleResetSubCommand(IMonitor monitor, ArgumentParser args)
|
||||
{
|
||||
if (!this.AssertEnabled(monitor))
|
||||
return;
|
||||
|
||||
if (args.TryGet(1, "type", out string type, false, new[] { "category", "source" }))
|
||||
{
|
||||
args.TryGet(2, "name", out string name);
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "category":
|
||||
SCore.PerformanceMonitor.ResetCollection(name);
|
||||
monitor.Log($"All performance counters for category {name} are now cleared.", LogLevel.Info);
|
||||
break;
|
||||
case "source":
|
||||
SCore.PerformanceMonitor.ResetSource(name);
|
||||
monitor.Log($"All performance counters for source {name} are now cleared.", LogLevel.Info);
|
||||
break;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SCore.PerformanceMonitor.Reset();
|
||||
monitor.Log("All performance counters are now cleared.", LogLevel.Info);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Formats the given milliseconds value into a string format. Optionally
|
||||
/// allows a threshold to return "-" if the value is less than the threshold.</summary>
|
||||
/// <param name="milliseconds">The milliseconds to format. Returns "-" if null</param>
|
||||
/// <param name="thresholdMilliseconds">The threshold. Any value below this is returned as "-".</param>
|
||||
/// <returns>The formatted milliseconds.</returns>
|
||||
private string FormatMilliseconds(double? milliseconds, double? thresholdMilliseconds = null)
|
||||
{
|
||||
thresholdMilliseconds ??= 1;
|
||||
return milliseconds != null && milliseconds >= thresholdMilliseconds
|
||||
? ((double)milliseconds).ToString("F2")
|
||||
: "-";
|
||||
}
|
||||
|
||||
/// <summary>Shows detailed help for a specific sub command.</summary>
|
||||
/// <param name="monitor">The output monitor.</param>
|
||||
/// <param name="subcommand">The subcommand.</param>
|
||||
private void OutputHelp(IMonitor monitor, SubCommand? subcommand)
|
||||
{
|
||||
StringBuilder report = new StringBuilder();
|
||||
report.AppendLine();
|
||||
|
||||
switch (subcommand)
|
||||
{
|
||||
case SubCommand.Detail:
|
||||
report.AppendLine($" {CommandName} detail <threshold>");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Displays details for a specific collection.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Arguments:");
|
||||
report.AppendLine(" <threshold> Optional. The threshold in milliseconds. Any average execution time below that");
|
||||
report.AppendLine(" threshold is not reported.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Examples:");
|
||||
report.AppendLine($"{CommandName} detail 5 Show counters exceeding an average of 5ms");
|
||||
break;
|
||||
|
||||
case SubCommand.Summary:
|
||||
report.AppendLine($"Usage: {CommandName} summary <threshold>");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Displays the performance counter summary.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Arguments:");
|
||||
report.AppendLine(" <threshold> Optional. Hides the actual execution time if it's below this threshold");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Examples:");
|
||||
report.AppendLine($"{CommandName} summary Show all events");
|
||||
report.AppendLine($"{CommandName} summary 5 Shows events exceeding an average of 5ms");
|
||||
break;
|
||||
|
||||
case SubCommand.Trigger:
|
||||
report.AppendLine($"Usage: {CommandName} trigger <mode>");
|
||||
report.AppendLine($"Usage: {CommandName} trigger collection <collectionName> <threshold>");
|
||||
report.AppendLine($"Usage: {CommandName} trigger collection <collectionName> <threshold> <sourceName>");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Manages alert triggers.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Arguments:");
|
||||
report.AppendLine(" <mode> Optional. Specifies if a specific source or a specific collection should be triggered.");
|
||||
report.AppendLine(" - list Lists current triggers");
|
||||
report.AppendLine(" - collection Sets up a trigger for a collection");
|
||||
report.AppendLine(" - clear Clears all trigger entries");
|
||||
report.AppendLine(" - pause Pauses triggering of alerts");
|
||||
report.AppendLine(" - resume Resumes triggering of alerts");
|
||||
report.AppendLine(" - dump Dumps all triggers as commands for copy and paste");
|
||||
report.AppendLine(" Defaults to 'list' if not specified.");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" <collectionName> Required if the mode 'collection' is specified.");
|
||||
report.AppendLine(" Specifies the name of the collection to be triggered. Must be an exact match.");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" <sourceName> Optional. Specifies the name of a specific source. Must be an exact match.");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" <threshold> Required if the mode 'collection' is specified.");
|
||||
report.AppendLine(" Specifies the threshold in milliseconds (fractions allowed).");
|
||||
report.AppendLine(" Specify '0' to remove the threshold.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Examples:");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"{CommandName} trigger collection Display.Rendering 10");
|
||||
report.AppendLine(" Sets up an alert trigger which writes on the console if the execution time of all performance counters in");
|
||||
report.AppendLine(" the 'Display.Rendering' collection exceed 10 milliseconds.");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"{CommandName} trigger collection Display.Rendering 5 Pathoschild.ChestsAnywhere");
|
||||
report.AppendLine(" Sets up an alert trigger to write on the console if the execution time of Pathoschild.ChestsAnywhere in");
|
||||
report.AppendLine(" the 'Display.Rendering' collection exceed 5 milliseconds.");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"{CommandName} trigger collection Display.Rendering 0");
|
||||
report.AppendLine(" Removes the threshold previously defined from the collection. Note that source-specific thresholds are left intact.");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"{CommandName} trigger clear");
|
||||
report.AppendLine(" Clears all previously setup alert triggers.");
|
||||
break;
|
||||
|
||||
case SubCommand.Reset:
|
||||
report.AppendLine($"Usage: {CommandName} reset <type> <name>");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Resets performance counters.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Arguments:");
|
||||
report.AppendLine(" <type> Optional. Specifies if a collection or source should be reset.");
|
||||
report.AppendLine(" If omitted, all performance counters are reset.");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" - source Clears performance counters for a specific source");
|
||||
report.AppendLine(" - collection Clears performance counters for a specific collection");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" <name> Required if a <type> is given. Specifies the name of either the collection");
|
||||
report.AppendLine(" or the source. The name must be an exact match.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Examples:");
|
||||
report.AppendLine($"{CommandName} reset Resets all performance counters");
|
||||
report.AppendLine($"{CommandName} reset source Pathoschild.ChestsAnywhere Resets all performance for the source named Pathoschild.ChestsAnywhere");
|
||||
report.AppendLine($"{CommandName} reset collection Display.Rendering Resets all performance for the collection named Display.Rendering");
|
||||
break;
|
||||
}
|
||||
|
||||
report.AppendLine();
|
||||
monitor.Log(report.ToString(), LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Get the command description.</summary>
|
||||
private static string GetDescription()
|
||||
{
|
||||
StringBuilder report = new StringBuilder();
|
||||
|
||||
report.AppendLine("Displays or configures performance monitoring to diagnose issues. Performance monitoring is disabled by default.");
|
||||
report.AppendLine();
|
||||
report.AppendLine("For example, the counter collection named 'Display.Rendered' contains one performance");
|
||||
report.AppendLine("counter when the game executes the 'Display.Rendered' event, and another counter for each mod which handles it.");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"Usage: {CommandName} <command> <action>");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Commands:");
|
||||
report.AppendLine();
|
||||
report.AppendLine(" summary Show a summary of collections.");
|
||||
report.AppendLine(" detail Show a summary for a given collection.");
|
||||
report.AppendLine(" reset Reset all performance counters.");
|
||||
report.AppendLine(" trigger Configure alert triggers.");
|
||||
report.AppendLine(" enable Enable performance counter recording.");
|
||||
report.AppendLine(" disable Disable performance counter recording.");
|
||||
report.AppendLine(" help Show verbose help for the available commands.");
|
||||
report.AppendLine();
|
||||
report.AppendLine($"To get help for a specific command, use '{CommandName} help <command>', for example:");
|
||||
report.AppendLine($"{CommandName} help summary");
|
||||
report.AppendLine();
|
||||
report.AppendLine("Defaults to summary if no command is given.");
|
||||
report.AppendLine();
|
||||
|
||||
return report.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Log a warning if performance monitoring isn't enabled.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <returns>Returns whether performance monitoring is enabled.</returns>
|
||||
private bool AssertEnabled(IMonitor monitor)
|
||||
{
|
||||
if (!SCore.PerformanceMonitor.EnableTracking)
|
||||
{
|
||||
monitor.Log($"Performance monitoring is currently disabled; enter '{CommandName} enable' to enable it.", LogLevel.Warn);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private models
|
||||
*********/
|
||||
/// <summary>An alert trigger for a collection.</summary>
|
||||
private class CollectionTrigger
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The collection name.</summary>
|
||||
public string CollectionName { get; }
|
||||
|
||||
/// <summary>The trigger threshold.</summary>
|
||||
public double Threshold { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="collectionName">The collection name.</param>
|
||||
/// <param name="threshold">The trigger threshold.</param>
|
||||
public CollectionTrigger(string collectionName, double threshold)
|
||||
{
|
||||
this.CollectionName = collectionName;
|
||||
this.Threshold = threshold;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>An alert triggered for a source.</summary>
|
||||
private class SourceTrigger : CollectionTrigger
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The source name.</summary>
|
||||
public string SourceName { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="collectionName">The collection name.</param>
|
||||
/// <param name="sourceName">The source name.</param>
|
||||
/// <param name="threshold">The trigger threshold.</param>
|
||||
public SourceTrigger(string collectionName, string sourceName, double threshold)
|
||||
: base(collectionName, threshold)
|
||||
{
|
||||
this.SourceName = sourceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Other
|
||||
{
|
||||
/// <summary>A command which logs the keys being pressed for 30 seconds once enabled.</summary>
|
||||
internal class TestInputCommand : TrainerCommand
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The number of seconds for which to log input.</summary>
|
||||
private readonly int LogSeconds = 30;
|
||||
|
||||
/// <summary>When the command should stop printing input, or <c>null</c> if currently disabled.</summary>
|
||||
private long? ExpiryTicks;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public TestInputCommand()
|
||||
: base("test_input", "Prints all input to the console for 30 seconds.", mayNeedUpdate: true, mayNeedInput: true) { }
|
||||
|
||||
/// <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)
|
||||
{
|
||||
this.ExpiryTicks = DateTime.UtcNow.Add(TimeSpan.FromSeconds(this.LogSeconds)).Ticks;
|
||||
monitor.Log($"OK, logging all player input for {this.LogSeconds} seconds.", LogLevel.Info);
|
||||
}
|
||||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public override void OnUpdated(IMonitor monitor)
|
||||
{
|
||||
// handle expiry
|
||||
if (this.ExpiryTicks == null)
|
||||
return;
|
||||
if (this.ExpiryTicks <= DateTime.UtcNow.Ticks)
|
||||
{
|
||||
monitor.Log("No longer logging input.", LogLevel.Info);
|
||||
this.ExpiryTicks = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Perform any logic when input is received.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="button">The button that was pressed.</param>
|
||||
public override void OnButtonPressed(IMonitor monitor, SButton button)
|
||||
{
|
||||
if (this.ExpiryTicks != null)
|
||||
monitor.Log($"Pressed {button}", LogLevel.Info);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Linq;
|
||||
using StardewValley;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
||||
|
@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
private bool InfiniteHealth;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
public override bool NeedsUpdate => this.InfiniteHealth;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public SetHealthCommand()
|
||||
: base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.") { }
|
||||
: base("player_sethealth", "Sets the player's health.\n\nUsage: player_sethealth [value]\n- value: an integer amount, or 'inf' for infinite health.", mayNeedUpdate: true) { }
|
||||
|
||||
/// <summary>Handle the command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
|
@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public override void Update(IMonitor monitor)
|
||||
public override void OnUpdated(IMonitor monitor)
|
||||
{
|
||||
if (this.InfiniteHealth)
|
||||
if (this.InfiniteHealth && Context.IsWorldReady)
|
||||
Game1.player.health = Game1.player.maxHealth;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
private bool InfiniteMoney;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
public override bool NeedsUpdate => this.InfiniteMoney;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public SetMoneyCommand()
|
||||
: base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.") { }
|
||||
: base("player_setmoney", "Sets the player's money.\n\nUsage: player_setmoney <value>\n- value: an integer amount, or 'inf' for infinite money.", mayNeedUpdate: true) { }
|
||||
|
||||
/// <summary>Handle the command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
|
@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public override void Update(IMonitor monitor)
|
||||
public override void OnUpdated(IMonitor monitor)
|
||||
{
|
||||
if (this.InfiniteMoney)
|
||||
if (this.InfiniteMoney && Context.IsWorldReady)
|
||||
Game1.player.Money = 999999;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Linq;
|
||||
using StardewValley;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
||||
|
@ -13,19 +13,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
private bool InfiniteStamina;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
public override bool NeedsUpdate => this.InfiniteStamina;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public SetStaminaCommand()
|
||||
: base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.") { }
|
||||
: base("player_setstamina", "Sets the player's stamina.\n\nUsage: player_setstamina [value]\n- value: an integer amount, or 'inf' for infinite stamina.", mayNeedUpdate: true) { }
|
||||
|
||||
/// <summary>Handle the command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
|
@ -62,9 +55,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.Player
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public override void Update(IMonitor monitor)
|
||||
public override void OnUpdated(IMonitor monitor)
|
||||
{
|
||||
if (this.InfiniteStamina)
|
||||
if (this.InfiniteStamina && Context.IsWorldReady)
|
||||
Game1.player.stamina = Game1.player.MaxStamina;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
|
@ -16,8 +16,11 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
/// <summary>The command description.</summary>
|
||||
public string Description { get; }
|
||||
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
public virtual bool NeedsUpdate { get; } = false;
|
||||
/// <summary>Whether the command may need to perform logic when the player presses a button. This value shouldn't change.</summary>
|
||||
public bool MayNeedInput { get; }
|
||||
|
||||
/// <summary>Whether the command may need to perform logic when the game updates. This value shouldn't change.</summary>
|
||||
public bool MayNeedUpdate { get; }
|
||||
|
||||
|
||||
/*********
|
||||
|
@ -31,7 +34,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public virtual void Update(IMonitor monitor) { }
|
||||
public virtual void OnUpdated(IMonitor monitor) { }
|
||||
|
||||
/// <summary>Perform any logic when input is received.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
/// <param name="button">The button that was pressed.</param>
|
||||
public virtual void OnButtonPressed(IMonitor monitor, SButton button) { }
|
||||
|
||||
|
||||
/*********
|
||||
|
@ -40,10 +48,14 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="name">The command name the user must type.</param>
|
||||
/// <param name="description">The command description.</param>
|
||||
protected TrainerCommand(string name, string description)
|
||||
/// <param name="mayNeedInput">Whether the command may need to perform logic when the player presses a button.</param>
|
||||
/// <param name="mayNeedUpdate">Whether the command may need to perform logic when the game updates.</param>
|
||||
protected TrainerCommand(string name, string description, bool mayNeedInput = false, bool mayNeedUpdate = false)
|
||||
{
|
||||
this.Name = name;
|
||||
this.Description = description;
|
||||
this.MayNeedInput = mayNeedInput;
|
||||
this.MayNeedUpdate = mayNeedUpdate;
|
||||
}
|
||||
|
||||
/// <summary>Log an error indicating incorrect usage.</summary>
|
||||
|
@ -66,7 +78,8 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
/// <param name="data">The data to display.</param>
|
||||
/// <param name="header">The table header.</param>
|
||||
/// <param name="getRow">Returns a set of fields for a data value.</param>
|
||||
protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow)
|
||||
/// <param name="rightAlign">Whether to right-align the data.</param>
|
||||
protected string GetTableString<T>(IEnumerable<T> data, string[] header, Func<T, string[]> getRow, bool rightAlign = false)
|
||||
{
|
||||
// get table data
|
||||
int[] widths = header.Select(p => p.Length).ToArray();
|
||||
|
@ -94,8 +107,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands
|
|||
|
||||
return string.Join(
|
||||
Environment.NewLine,
|
||||
lines.Select(line => string.Join(" | ", line.Select((field, i) => field.PadRight(widths[i], ' ')).ToArray())
|
||||
)
|
||||
lines.Select(line => string.Join(" | ",
|
||||
line.Select((field, i) => rightAlign ? field.PadRight(widths[i], ' ') : field.PadLeft(widths[i], ' '))
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
using System.Linq;
|
||||
using System.Linq;
|
||||
using StardewValley;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
|
||||
|
@ -16,19 +16,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
|
|||
private bool FreezeTime;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>Whether the command needs to perform logic when the game updates.</summary>
|
||||
public override bool NeedsUpdate => this.FreezeTime;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public FreezeTimeCommand()
|
||||
: base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).") { }
|
||||
: base("world_freezetime", "Freezes or resumes time.\n\nUsage: world_freezetime [value]\n- value: one of 0 (resume), 1 (freeze), or blank (toggle).", mayNeedUpdate: true) { }
|
||||
|
||||
/// <summary>Handle the command.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
|
@ -57,9 +50,9 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands.World
|
|||
|
||||
/// <summary>Perform any logic needed on update tick.</summary>
|
||||
/// <param name="monitor">Writes messages to the console and log file.</param>
|
||||
public override void Update(IMonitor monitor)
|
||||
public override void OnUpdated(IMonitor monitor)
|
||||
{
|
||||
if (this.FreezeTime)
|
||||
if (this.FreezeTime && Context.IsWorldReady)
|
||||
Game1.timeOfDay = FreezeTimeCommand.FrozenTime;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StardewModdingAPI.Events;
|
||||
using StardewModdingAPI.Mods.ConsoleCommands.Framework.Commands;
|
||||
|
||||
namespace StardewModdingAPI.Mods.ConsoleCommands
|
||||
|
@ -14,6 +15,12 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
|
|||
/// <summary>The commands to handle.</summary>
|
||||
private ITrainerCommand[] Commands;
|
||||
|
||||
/// <summary>The commands which may need to handle update ticks.</summary>
|
||||
private ITrainerCommand[] UpdateHandlers;
|
||||
|
||||
/// <summary>The commands which may need to handle input.</summary>
|
||||
private ITrainerCommand[] InputHandlers;
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
|
@ -27,27 +34,35 @@ namespace StardewModdingAPI.Mods.ConsoleCommands
|
|||
foreach (ITrainerCommand command in this.Commands)
|
||||
helper.ConsoleCommands.Add(command.Name, command.Description, (name, args) => this.HandleCommand(command, name, args));
|
||||
|
||||
// cache commands
|
||||
this.InputHandlers = this.Commands.Where(p => p.MayNeedInput).ToArray();
|
||||
this.UpdateHandlers = this.Commands.Where(p => p.MayNeedUpdate).ToArray();
|
||||
|
||||
// hook events
|
||||
helper.Events.GameLoop.UpdateTicked += this.OnUpdateTicked;
|
||||
helper.Events.Input.ButtonPressed += this.OnButtonPressed;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>The method invoked when a button is pressed.</summary>
|
||||
/// <param name="sender">The event sender.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void OnButtonPressed(object sender, ButtonPressedEventArgs e)
|
||||
{
|
||||
foreach (ITrainerCommand command in this.InputHandlers)
|
||||
command.OnButtonPressed(this.Monitor, e.Button);
|
||||
}
|
||||
|
||||
/// <summary>The method invoked when the game updates its state.</summary>
|
||||
/// <param name="sender">The event sender.</param>
|
||||
/// <param name="e">The event arguments.</param>
|
||||
private void OnUpdateTicked(object sender, EventArgs e)
|
||||
{
|
||||
if (!Context.IsWorldReady)
|
||||
return;
|
||||
|
||||
foreach (ITrainerCommand command in this.Commands)
|
||||
{
|
||||
if (command.NeedsUpdate)
|
||||
command.Update(this.Monitor);
|
||||
}
|
||||
foreach (ITrainerCommand command in this.UpdateHandlers)
|
||||
command.OnUpdated(this.Monitor);
|
||||
}
|
||||
|
||||
/// <summary>Handle a console command.</summary>
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
<RootNamespace>StardewModdingAPI.Mods.ConsoleCommands</RootNamespace>
|
||||
<TargetFramework>net45</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<OutputPath>$(SolutionDir)\..\bin\$(Configuration)\Mods\ConsoleCommands</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"Name": "Console Commands",
|
||||
"Author": "SMAPI",
|
||||
"Version": "3.1.0",
|
||||
"Version": "3.2.0",
|
||||
"Description": "Adds SMAPI console commands that let you manipulate the game.",
|
||||
"UniqueID": "SMAPI.ConsoleCommands",
|
||||
"EntryDll": "ConsoleCommands.dll",
|
||||
"MinimumApiVersion": "3.1.0"
|
||||
"MinimumApiVersion": "3.2.0"
|
||||
}
|
||||
|
|
|
@ -199,7 +199,7 @@ namespace StardewModdingAPI.Mods.SaveBackup
|
|||
/// <param name="copyRoot">Whether to copy the root folder itself, or <c>false</c> to only copy its contents.</param>
|
||||
/// <param name="filter">A filter which matches the files or directories to copy, or <c>null</c> to copy everything.</param>
|
||||
/// <remarks>Derived from the SMAPI installer code.</remarks>
|
||||
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter = null, bool copyRoot = true)
|
||||
private void RecursiveCopy(FileSystemInfo source, DirectoryInfo targetFolder, Func<FileSystemInfo, bool> filter, bool copyRoot = true)
|
||||
{
|
||||
if (!targetFolder.Exists)
|
||||
targetFolder.Create();
|
||||
|
@ -229,22 +229,16 @@ namespace StardewModdingAPI.Mods.SaveBackup
|
|||
/// <param name="entry">The current entry to check under <paramref name="savesFolder"/>.</param>
|
||||
private bool MatchSaveFolders(DirectoryInfo savesFolder, FileSystemInfo entry)
|
||||
{
|
||||
this.Monitor.Log($"Checking {entry.FullName}...");
|
||||
|
||||
// only need to filter top-level entries
|
||||
string parentPath = (entry as FileInfo)?.DirectoryName ?? (entry as DirectoryInfo)?.Parent?.FullName;
|
||||
if (parentPath != savesFolder.FullName)
|
||||
{
|
||||
this.Monitor.Log(" OK: not root path");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// match folders with Name_ID format
|
||||
bool include =
|
||||
return
|
||||
entry is DirectoryInfo
|
||||
&& ulong.TryParse(entry.Name.Split('_').Last(), out _);
|
||||
this.Monitor.Log(include ? " OK: name matches save folder format" : " SKIP: not a save folder");
|
||||
return include;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"Name": "Save Backup",
|
||||
"Author": "SMAPI",
|
||||
"Version": "3.1.0",
|
||||
"Version": "3.2.0",
|
||||
"Description": "Automatically backs up all your saves once per day into its folder.",
|
||||
"UniqueID": "SMAPI.SaveBackup",
|
||||
"EntryDll": "SaveBackup.dll",
|
||||
"MinimumApiVersion": "3.1.0"
|
||||
"MinimumApiVersion": "3.2.0"
|
||||
}
|
||||
|
|
|
@ -17,7 +17,8 @@ namespace SMAPI.Tests.Utilities
|
|||
/****
|
||||
** Constructor
|
||||
****/
|
||||
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from a string.")]
|
||||
/// <summary>Assert the parsed version when constructed from a standard string.</summary>
|
||||
/// <param name="input">The version string to parse.</param>
|
||||
[TestCase("1.0", ExpectedResult = "1.0.0")]
|
||||
[TestCase("1.0.0", ExpectedResult = "1.0.0")]
|
||||
[TestCase("3000.4000.5000", ExpectedResult = "3000.4000.5000")]
|
||||
|
@ -28,65 +29,17 @@ namespace SMAPI.Tests.Utilities
|
|||
[TestCase("1.2.3-some-tag.4+build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
|
||||
[TestCase("1.2+3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
|
||||
public string Constructor_FromString(string input)
|
||||
{
|
||||
return new SemanticVersion(input).ToString();
|
||||
}
|
||||
|
||||
[Test(Description = "Assert that the constructor sets the expected values for all valid versions when constructed from the individual numbers.")]
|
||||
[TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")]
|
||||
[TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")]
|
||||
[TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")]
|
||||
[TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")]
|
||||
[TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")]
|
||||
[TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")]
|
||||
[TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")]
|
||||
[TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")]
|
||||
[TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
|
||||
[TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
|
||||
public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);
|
||||
ISemanticVersion version = new SemanticVersion(input);
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value.");
|
||||
Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value.");
|
||||
Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value.");
|
||||
Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match the given value.");
|
||||
Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match the given value.");
|
||||
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, null)]
|
||||
[TestCase(-1, 0, 0, null, null)]
|
||||
[TestCase(0, -1, 0, null, null)]
|
||||
[TestCase(0, 0, -1, null, null)]
|
||||
[TestCase(1, 0, 0, "-tag", null)]
|
||||
[TestCase(1, 0, 0, "tag spaces", null)]
|
||||
[TestCase(1, 0, 0, "tag~", null)]
|
||||
[TestCase(1, 0, 0, null, "build~")]
|
||||
public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build)
|
||||
{
|
||||
this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build));
|
||||
}
|
||||
|
||||
[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.0")]
|
||||
[TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
|
||||
[TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")]
|
||||
public string Constructor_FromAssemblyVersion(int major, int minor, int patch)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch));
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match the given value.");
|
||||
Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match the given value.");
|
||||
Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match the given value.");
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Assert that the constructor rejects invalid values when constructed from a string.</summary>
|
||||
/// <param name="input">The version string to parse.</param>
|
||||
[Test(Description = "Assert that the constructor throws the expected exception for invalid versions.")]
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
|
@ -116,10 +69,127 @@ namespace SMAPI.Tests.Utilities
|
|||
this.AssertAndLogException<FormatException>(() => new SemanticVersion(input));
|
||||
}
|
||||
|
||||
/// <summary>Assert the parsed version when constructed from a non-standard string.</summary>
|
||||
/// <param name="input">The version string to parse.</param>
|
||||
[TestCase("1.2.3", ExpectedResult = "1.2.3")]
|
||||
[TestCase("1.0.0.0", ExpectedResult = "1.0.0")]
|
||||
[TestCase("1.0.0.5", ExpectedResult = "1.0.0.5")]
|
||||
[TestCase("1.2.3.4-some-tag.4 ", ExpectedResult = "1.2.3.4-some-tag.4")]
|
||||
public string Constructor_FromString_NonStandard(string input)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(input, allowNonStandard: true);
|
||||
|
||||
// assert
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Assert that the constructor rejects a non-standard string when the non-standard flag isn't set.</summary>
|
||||
/// <param name="input">The version string to parse.</param>
|
||||
[TestCase("1.0.0.0")]
|
||||
[TestCase("1.0.0.5")]
|
||||
[TestCase("1.2.3.4-some-tag.4 ")]
|
||||
public void Constructor_FromString_Standard_DisallowsNonStandardVersion(string input)
|
||||
{
|
||||
Assert.Throws<FormatException>(() => new SemanticVersion(input));
|
||||
}
|
||||
|
||||
/// <summary>Assert the parsed version when constructed from standard parts.</summary>
|
||||
/// <param name="major">The major number.</param>
|
||||
/// <param name="minor">The minor number.</param>
|
||||
/// <param name="patch">The patch number.</param>
|
||||
/// <param name="prerelease">The prerelease tag.</param>
|
||||
/// <param name="build">The build metadata.</param>
|
||||
[TestCase(1, 0, 0, null, null, ExpectedResult = "1.0.0")]
|
||||
[TestCase(3000, 4000, 5000, null, null, ExpectedResult = "3000.4000.5000")]
|
||||
[TestCase(1, 2, 3, "", null, ExpectedResult = "1.2.3")]
|
||||
[TestCase(1, 2, 3, " ", null, ExpectedResult = "1.2.3")]
|
||||
[TestCase(1, 2, 3, "0", null, ExpectedResult = "1.2.3-0")]
|
||||
[TestCase(1, 2, 3, "some-tag.4", null, ExpectedResult = "1.2.3-some-tag.4")]
|
||||
[TestCase(1, 2, 3, "sOMe-TaG.4", null, ExpectedResult = "1.2.3-sOMe-TaG.4")]
|
||||
[TestCase(1, 2, 3, "some-tag.4 ", null, ExpectedResult = "1.2.3-some-tag.4")]
|
||||
[TestCase(1, 2, 3, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3-some-tag.4+build.004")]
|
||||
[TestCase(1, 2, 0, null, "3.4.5-build.004", ExpectedResult = "1.2.0+3.4.5-build.004")]
|
||||
public string Constructor_FromParts(int major, int minor, int patch, string prerelease, string build)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(major, minor, patch, prerelease, build);
|
||||
|
||||
// assert
|
||||
this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: false);
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Assert the parsed version when constructed from parts including non-standard fields.</summary>
|
||||
/// <param name="major">The major number.</param>
|
||||
/// <param name="minor">The minor number.</param>
|
||||
/// <param name="patch">The patch number.</param>
|
||||
/// <param name="platformRelease">The non-standard platform release number.</param>
|
||||
/// <param name="prerelease">The prerelease tag.</param>
|
||||
/// <param name="build">The build metadata.</param>
|
||||
[TestCase(1, 0, 0, 0, null, null, ExpectedResult = "1.0.0")]
|
||||
[TestCase(3000, 4000, 5000, 6000, null, null, ExpectedResult = "3000.4000.5000.6000")]
|
||||
[TestCase(1, 2, 3, 4, "", null, ExpectedResult = "1.2.3.4")]
|
||||
[TestCase(1, 2, 3, 4, " ", null, ExpectedResult = "1.2.3.4")]
|
||||
[TestCase(1, 2, 3, 4, "0", null, ExpectedResult = "1.2.3.4-0")]
|
||||
[TestCase(1, 2, 3, 4, "some-tag.4", null, ExpectedResult = "1.2.3.4-some-tag.4")]
|
||||
[TestCase(1, 2, 3, 4, "sOMe-TaG.4", null, ExpectedResult = "1.2.3.4-sOMe-TaG.4")]
|
||||
[TestCase(1, 2, 3, 4, "some-tag.4 ", null, ExpectedResult = "1.2.3.4-some-tag.4")]
|
||||
[TestCase(1, 2, 3, 4, "some-tag.4 ", "build.004", ExpectedResult = "1.2.3.4-some-tag.4+build.004")]
|
||||
[TestCase(1, 2, 0, 4, null, "3.4.5-build.004", ExpectedResult = "1.2.0.4+3.4.5-build.004")]
|
||||
public string Constructor_FromParts_NonStandard(int major, int minor, int patch, int platformRelease, string prerelease, string build)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(major, minor, patch, platformRelease, prerelease, build);
|
||||
|
||||
// assert
|
||||
this.AssertParts(version, major, minor, patch, prerelease, build, nonStandard: platformRelease != 0);
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Assert that the constructor rejects invalid values when constructed from the individual numbers.</summary>
|
||||
/// <param name="major">The major number.</param>
|
||||
/// <param name="minor">The minor number.</param>
|
||||
/// <param name="patch">The patch number.</param>
|
||||
/// <param name="prerelease">The prerelease tag.</param>
|
||||
/// <param name="build">The build metadata.</param>
|
||||
[TestCase(0, 0, 0, null, null)]
|
||||
[TestCase(-1, 0, 0, null, null)]
|
||||
[TestCase(0, -1, 0, null, null)]
|
||||
[TestCase(0, 0, -1, null, null)]
|
||||
[TestCase(1, 0, 0, "-tag", null)]
|
||||
[TestCase(1, 0, 0, "tag spaces", null)]
|
||||
[TestCase(1, 0, 0, "tag~", null)]
|
||||
[TestCase(1, 0, 0, null, "build~")]
|
||||
public void Constructor_FromParts_WithInvalidValues(int major, int minor, int patch, string prerelease, string build)
|
||||
{
|
||||
this.AssertAndLogException<FormatException>(() => new SemanticVersion(major, minor, patch, prerelease, build));
|
||||
}
|
||||
|
||||
/// <summary>Assert the parsed version when constructed from an assembly version.</summary>
|
||||
/// <param name="major">The major number.</param>
|
||||
/// <param name="minor">The minor number.</param>
|
||||
/// <param name="patch">The patch number.</param>
|
||||
[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.0")]
|
||||
[TestCase(1, 2, 3, ExpectedResult = "1.2.3")]
|
||||
[TestCase(3000, 4000, 5000, ExpectedResult = "3000.4000.5000")]
|
||||
public string Constructor_FromAssemblyVersion(int major, int minor, int patch)
|
||||
{
|
||||
// act
|
||||
ISemanticVersion version = new SemanticVersion(new Version(major, minor, patch));
|
||||
|
||||
// assert
|
||||
this.AssertParts(version, major, minor, patch, null, null, nonStandard: false);
|
||||
return version.ToString();
|
||||
}
|
||||
|
||||
/****
|
||||
** CompareTo
|
||||
****/
|
||||
[Test(Description = "Assert that version.CompareTo returns the expected value.")]
|
||||
/// <summary>Assert that <see cref="ISemanticVersion.CompareTo"/> returns the expected value.</summary>
|
||||
/// <param name="versionStrA">The left version.</param>
|
||||
/// <param name="versionStrB">The right version.</param>
|
||||
// equal
|
||||
[TestCase("0.5.7", "0.5.7", ExpectedResult = 0)]
|
||||
[TestCase("1.0", "1.0", ExpectedResult = 0)]
|
||||
|
@ -149,15 +219,20 @@ namespace SMAPI.Tests.Utilities
|
|||
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = 1)]
|
||||
public int CompareTo(string versionStrA, string versionStrB)
|
||||
{
|
||||
// arrange
|
||||
ISemanticVersion versionA = new SemanticVersion(versionStrA);
|
||||
ISemanticVersion versionB = new SemanticVersion(versionStrB);
|
||||
|
||||
// assert
|
||||
return versionA.CompareTo(versionB);
|
||||
}
|
||||
|
||||
/****
|
||||
** IsOlderThan
|
||||
****/
|
||||
[Test(Description = "Assert that version.IsOlderThan returns the expected value.")]
|
||||
/// <summary>Assert that <see cref="ISemanticVersion.IsOlderThan(string)"/> and <see cref="ISemanticVersion.IsOlderThan(ISemanticVersion)"/> return the expected value.</summary>
|
||||
/// <param name="versionStrA">The left version.</param>
|
||||
/// <param name="versionStrB">The right version.</param>
|
||||
// keep test cases in sync with CompareTo for simplicity.
|
||||
// equal
|
||||
[TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
|
||||
|
@ -187,15 +262,21 @@ namespace SMAPI.Tests.Utilities
|
|||
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = false)]
|
||||
public bool IsOlderThan(string versionStrA, string versionStrB)
|
||||
{
|
||||
// arrange
|
||||
ISemanticVersion versionA = new SemanticVersion(versionStrA);
|
||||
ISemanticVersion versionB = new SemanticVersion(versionStrB);
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(versionA.IsOlderThan(versionB), versionA.IsOlderThan(versionB.ToString()), "The two signatures returned different results.");
|
||||
return versionA.IsOlderThan(versionB);
|
||||
}
|
||||
|
||||
/****
|
||||
** IsNewerThan
|
||||
****/
|
||||
[Test(Description = "Assert that version.IsNewerThan returns the expected value.")]
|
||||
/// <summary>Assert that <see cref="ISemanticVersion.IsNewerThan(string)"/> and <see cref="ISemanticVersion.IsNewerThan(ISemanticVersion)"/> return the expected value.</summary>
|
||||
/// <param name="versionStrA">The left version.</param>
|
||||
/// <param name="versionStrB">The right version.</param>
|
||||
// keep test cases in sync with CompareTo for simplicity.
|
||||
// equal
|
||||
[TestCase("0.5.7", "0.5.7", ExpectedResult = false)]
|
||||
|
@ -225,14 +306,22 @@ namespace SMAPI.Tests.Utilities
|
|||
[TestCase("1.0-beta-10", "1.0-beta-2", ExpectedResult = true)]
|
||||
public bool IsNewerThan(string versionStrA, string versionStrB)
|
||||
{
|
||||
// arrange
|
||||
ISemanticVersion versionA = new SemanticVersion(versionStrA);
|
||||
ISemanticVersion versionB = new SemanticVersion(versionStrB);
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(versionA.IsNewerThan(versionB), versionA.IsNewerThan(versionB.ToString()), "The two signatures returned different results.");
|
||||
return versionA.IsNewerThan(versionB);
|
||||
}
|
||||
|
||||
/****
|
||||
** IsBetween
|
||||
****/
|
||||
/// <summary>Assert that <see cref="ISemanticVersion.IsBetween(string, string)"/> and <see cref="ISemanticVersion.IsBetween(ISemanticVersion, ISemanticVersion)"/> return the expected value.</summary>
|
||||
/// <param name="versionStr">The main version.</param>
|
||||
/// <param name="lowerStr">The lower version number.</param>
|
||||
/// <param name="upperStr">The upper version number.</param>
|
||||
[Test(Description = "Assert that version.IsNewerThan returns the expected value.")]
|
||||
// is between
|
||||
[TestCase("0.5.7-beta.3", "0.5.7-beta.3", "0.5.7-beta.3", ExpectedResult = true)]
|
||||
|
@ -250,17 +339,24 @@ namespace SMAPI.Tests.Utilities
|
|||
[TestCase("1.0-beta-2", "1.0-beta-10", "1.0-beta-3", ExpectedResult = false)]
|
||||
public bool IsBetween(string versionStr, string lowerStr, string upperStr)
|
||||
{
|
||||
// arrange
|
||||
ISemanticVersion lower = new SemanticVersion(lowerStr);
|
||||
ISemanticVersion upper = new SemanticVersion(upperStr);
|
||||
ISemanticVersion version = new SemanticVersion(versionStr);
|
||||
|
||||
// assert
|
||||
Assert.AreEqual(version.IsBetween(lower, upper), version.IsBetween(lower.ToString(), upper.ToString()), "The two signatures returned different results.");
|
||||
return version.IsBetween(lower, upper);
|
||||
}
|
||||
|
||||
/****
|
||||
** Serializable
|
||||
****/
|
||||
[Test(Description = "Assert that SemanticVersion can be round-tripped through JSON with no special configuration.")]
|
||||
/// <summary>Assert that the version can be round-tripped through JSON with no special configuration.</summary>
|
||||
/// <param name="versionStr">The semantic version.</param>
|
||||
[TestCase("1.0.0")]
|
||||
[TestCase("1.0.0-beta.400")]
|
||||
[TestCase("1.0.0-beta.400+build")]
|
||||
public void Serializable(string versionStr)
|
||||
{
|
||||
// act
|
||||
|
@ -272,10 +368,12 @@ namespace SMAPI.Tests.Utilities
|
|||
Assert.AreEqual(versionStr, after.ToString(), "The semantic version after deserialization doesn't match the input version.");
|
||||
}
|
||||
|
||||
|
||||
/****
|
||||
** GameVersion
|
||||
****/
|
||||
[Test(Description = "Assert that the GameVersion subclass correctly parses legacy game versions.")]
|
||||
/// <summary>Assert that the GameVersion subclass correctly parses non-standard game versions.</summary>
|
||||
/// <param name="versionStr">The raw version.</param>
|
||||
[TestCase("1.0")]
|
||||
[TestCase("1.01")]
|
||||
[TestCase("1.02")]
|
||||
|
@ -307,6 +405,24 @@ namespace SMAPI.Tests.Utilities
|
|||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Assert that the version matches the expected parts.</summary>
|
||||
/// <param name="version">The version number.</param>
|
||||
/// <param name="major">The major number.</param>
|
||||
/// <param name="minor">The minor number.</param>
|
||||
/// <param name="patch">The patch number.</param>
|
||||
/// <param name="prerelease">The prerelease tag.</param>
|
||||
/// <param name="build">The build metadata.</param>
|
||||
/// <param name="nonStandard">Whether the version should be marked as non-standard.</param>
|
||||
private void AssertParts(ISemanticVersion version, int major, int minor, int patch, string prerelease, string build, bool nonStandard)
|
||||
{
|
||||
Assert.AreEqual(major, version.MajorVersion, "The major version doesn't match.");
|
||||
Assert.AreEqual(minor, version.MinorVersion, "The minor version doesn't match.");
|
||||
Assert.AreEqual(patch, version.PatchVersion, "The patch version doesn't match.");
|
||||
Assert.AreEqual(string.IsNullOrWhiteSpace(prerelease) ? null : prerelease.Trim(), version.PrereleaseTag, "The prerelease tag doesn't match.");
|
||||
Assert.AreEqual(string.IsNullOrWhiteSpace(build) ? null : build.Trim(), version.BuildMetadata, "The build metadata doesn't match.");
|
||||
Assert.AreEqual(nonStandard, version.IsNonStandard(), $"The version is incorrectly marked {(nonStandard ? "standard" : "non-standard")}.");
|
||||
}
|
||||
|
||||
/// <summary>Assert that the expected exception type is thrown, and log the action output and thrown exception.</summary>
|
||||
/// <typeparam name="T">The expected exception type.</typeparam>
|
||||
/// <param name="action">The action which may throw the exception.</param>
|
||||
|
|
|
@ -61,5 +61,8 @@ namespace StardewModdingAPI
|
|||
|
||||
/// <summary>Get a string representation of the version.</summary>
|
||||
string ToString();
|
||||
|
||||
/// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
|
||||
bool IsNonStandard();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,8 @@
|
|||
<RootNamespace>StardewModdingAPI</RootNamespace>
|
||||
<Description>Provides toolkit interfaces which are available to SMAPI mods.</Description>
|
||||
<TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
|
||||
<OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces</OutputPath>
|
||||
<DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit.CoreInterfaces\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.CoreInterfaces.xml</DocumentationFile>
|
||||
<PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
using System;
|
||||
|
||||
namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
|
||||
{
|
||||
/// <summary>Metadata about a mod.</summary>
|
||||
|
@ -17,26 +15,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
|
|||
/// <summary>Optional extended data which isn't needed for update checks.</summary>
|
||||
public ModExtendedMetadataModel Metadata { get; set; }
|
||||
|
||||
/// <summary>The main version.</summary>
|
||||
[Obsolete]
|
||||
public ModEntryVersionModel Main { get; set; }
|
||||
|
||||
/// <summary>The latest optional version, if newer than <see cref="Main"/>.</summary>
|
||||
[Obsolete]
|
||||
public ModEntryVersionModel Optional { get; set; }
|
||||
|
||||
/// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
|
||||
[Obsolete]
|
||||
public ModEntryVersionModel Unofficial { get; set; }
|
||||
|
||||
/// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary>
|
||||
[Obsolete]
|
||||
public ModEntryVersionModel UnofficialForBeta { get; set; }
|
||||
|
||||
/// <summary>Whether a Stardew Valley or SMAPI beta which affects mod compatibility is in progress. If this is true, <see cref="UnofficialForBeta"/> should be used for beta versions of SMAPI instead of <see cref="Unofficial"/>.</summary>
|
||||
[Obsolete]
|
||||
public bool? HasBetaInfo { get; set; }
|
||||
|
||||
/// <summary>The errors that occurred while fetching update data.</summary>
|
||||
public string[] Errors { get; set; } = new string[0];
|
||||
}
|
||||
|
|
|
@ -55,7 +55,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
|
|||
/// <summary>The latest unofficial version, if newer than <see cref="Main"/> and <see cref="Optional"/>.</summary>
|
||||
public ModEntryVersionModel Unofficial { get; set; }
|
||||
|
||||
/// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any (see <see cref="HasBetaInfo"/>).</summary>
|
||||
/// <summary>The latest unofficial version for the current Stardew Valley or SMAPI beta, if any.</summary>
|
||||
public ModEntryVersionModel UnofficialForBeta { get; set; }
|
||||
|
||||
/****
|
||||
|
@ -84,6 +84,15 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
|
|||
/// <summary>The beta game or SMAPI version which broke this mod, if applicable.</summary>
|
||||
public string BetaBrokeIn { get; set; }
|
||||
|
||||
/****
|
||||
** Version mappings
|
||||
****/
|
||||
/// <summary>Maps local versions to a semantic version for update checks.</summary>
|
||||
public IDictionary<string, string> MapLocalVersions { get; set; }
|
||||
|
||||
/// <summary>Maps remote versions to a semantic version for update checks.</summary>
|
||||
public IDictionary<string, string> MapRemoteVersions { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
|
@ -127,13 +136,16 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.WebApi
|
|||
this.BetaCompatibilityStatus = wiki.BetaCompatibility?.Status;
|
||||
this.BetaCompatibilitySummary = wiki.BetaCompatibility?.Summary;
|
||||
this.BetaBrokeIn = wiki.BetaCompatibility?.BrokeIn;
|
||||
|
||||
this.MapLocalVersions = wiki.MapLocalVersions;
|
||||
this.MapRemoteVersions = wiki.MapRemoteVersions;
|
||||
}
|
||||
|
||||
// internal DB data
|
||||
if (db != null)
|
||||
{
|
||||
this.ID = this.ID.Union(db.FormerIDs).ToArray();
|
||||
this.Name = this.Name ?? db.DisplayName;
|
||||
this.Name ??= db.DisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -102,6 +102,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
|
|||
string anchor = this.GetAttribute(node, "id");
|
||||
string contentPackFor = this.GetAttribute(node, "data-content-pack-for");
|
||||
string devNote = this.GetAttribute(node, "data-dev-note");
|
||||
string pullRequestUrl = this.GetAttribute(node, "data-pr");
|
||||
IDictionary<string, string> mapLocalVersions = this.GetAttributeAsVersionMapping(node, "data-map-local-versions");
|
||||
IDictionary<string, string> mapRemoteVersions = this.GetAttributeAsVersionMapping(node, "data-map-remote-versions");
|
||||
|
||||
|
@ -132,15 +133,6 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
|
|||
}
|
||||
}
|
||||
|
||||
// parse links
|
||||
List<Tuple<Uri, string>> metadataLinks = new List<Tuple<Uri, string>>();
|
||||
foreach (HtmlNode linkElement in node.Descendants("td").Last().Descendants("a").Skip(1)) // skip anchor link
|
||||
{
|
||||
string text = linkElement.InnerText.Trim();
|
||||
Uri url = new Uri(linkElement.GetAttributeValue("href", ""));
|
||||
metadataLinks.Add(Tuple.Create(url, text));
|
||||
}
|
||||
|
||||
// yield model
|
||||
yield return new WikiModEntry
|
||||
{
|
||||
|
@ -159,7 +151,7 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
|
|||
Compatibility = compatibility,
|
||||
BetaCompatibility = betaCompatibility,
|
||||
Warnings = warnings,
|
||||
MetadataLinks = metadataLinks.ToArray(),
|
||||
PullRequestUrl = pullRequestUrl,
|
||||
DevNote = devNote,
|
||||
MapLocalVersions = mapLocalVersions,
|
||||
MapRemoteVersions = mapRemoteVersions,
|
||||
|
|
|
@ -57,8 +57,8 @@ namespace StardewModdingAPI.Toolkit.Framework.Clients.Wiki
|
|||
/// <summary>The human-readable warnings for players about this mod.</summary>
|
||||
public string[] Warnings { get; set; }
|
||||
|
||||
/// <summary>Extra metadata links (usually for open pull requests).</summary>
|
||||
public Tuple<Uri, string>[] MetadataLinks { get; set; }
|
||||
/// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
|
||||
public string PullRequestUrl { get; set; }
|
||||
|
||||
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
|
||||
public string DevNote { get; set; }
|
||||
|
|
|
@ -0,0 +1,126 @@
|
|||
namespace StardewModdingAPI.Toolkit.Framework
|
||||
{
|
||||
/// <summary>Reads strings into a semantic version.</summary>
|
||||
internal static class SemanticVersionReader
|
||||
{
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Parse a semantic version string.</summary>
|
||||
/// <param name="versionStr">The version string to parse.</param>
|
||||
/// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</param>
|
||||
/// <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 fixes.</param>
|
||||
/// <param name="platformRelease">The platform-specific version (if applicable).</param>
|
||||
/// <param name="prereleaseTag">An optional prerelease tag.</param>
|
||||
/// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
|
||||
/// <returns>Returns whether the version was successfully parsed.</returns>
|
||||
public static bool TryParse(string versionStr, bool allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata)
|
||||
{
|
||||
// init
|
||||
major = 0;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
platformRelease = 0;
|
||||
prereleaseTag = null;
|
||||
buildMetadata = null;
|
||||
|
||||
// normalize
|
||||
versionStr = versionStr?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(versionStr))
|
||||
return false;
|
||||
char[] raw = versionStr.ToCharArray();
|
||||
|
||||
// read major/minor version
|
||||
int i = 0;
|
||||
if (!TryParseVersionPart(raw, ref i, out major) || !TryParseLiteral(raw, ref i, '.') || !TryParseVersionPart(raw, ref i, out minor))
|
||||
return false;
|
||||
|
||||
// read optional patch version
|
||||
if (TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out patch))
|
||||
return false;
|
||||
|
||||
// read optional non-standard platform release version
|
||||
if (allowNonStandard && TryParseLiteral(raw, ref i, '.') && !TryParseVersionPart(raw, ref i, out platformRelease))
|
||||
return false;
|
||||
|
||||
// read optional prerelease tag
|
||||
if (TryParseLiteral(raw, ref i, '-') && !TryParseTag(raw, ref i, out prereleaseTag))
|
||||
return false;
|
||||
|
||||
// read optional build tag
|
||||
if (TryParseLiteral(raw, ref i, '+') && !TryParseTag(raw, ref i, out buildMetadata))
|
||||
return false;
|
||||
|
||||
// validate
|
||||
return i == versionStr.Length; // valid if we're at the end
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Try to parse the next characters in a queue as a numeric part.</summary>
|
||||
/// <param name="raw">The raw characters to parse.</param>
|
||||
/// <param name="index">The index of the next character to read.</param>
|
||||
/// <param name="part">The parsed part.</param>
|
||||
private static bool TryParseVersionPart(char[] raw, ref int index, out int part)
|
||||
{
|
||||
part = 0;
|
||||
|
||||
// take digits
|
||||
string str = "";
|
||||
for (int i = index; i < raw.Length && char.IsDigit(raw[i]); i++)
|
||||
str += raw[i];
|
||||
|
||||
// validate
|
||||
if (str.Length == 0)
|
||||
return false;
|
||||
if (str.Length > 1 && str[0] == '0')
|
||||
return false; // can't have leading zeros
|
||||
|
||||
// parse
|
||||
part = int.Parse(str);
|
||||
index += str.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Try to parse a literal character.</summary>
|
||||
/// <param name="raw">The raw characters to parse.</param>
|
||||
/// <param name="index">The index of the next character to read.</param>
|
||||
/// <param name="ch">The expected character.</param>
|
||||
private static bool TryParseLiteral(char[] raw, ref int index, char ch)
|
||||
{
|
||||
if (index >= raw.Length || raw[index] != ch)
|
||||
return false;
|
||||
|
||||
index++;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Try to parse a tag.</summary>
|
||||
/// <param name="raw">The raw characters to parse.</param>
|
||||
/// <param name="index">The index of the next character to read.</param>
|
||||
/// <param name="tag">The parsed tag.</param>
|
||||
private static bool TryParseTag(char[] raw, ref int index, out string tag)
|
||||
{
|
||||
// read tag length
|
||||
int length = 0;
|
||||
for (int i = index; i < raw.Length && (char.IsLetterOrDigit(raw[i]) || raw[i] == '-' || raw[i] == '.'); i++)
|
||||
length++;
|
||||
|
||||
// validate
|
||||
if (length == 0)
|
||||
{
|
||||
tag = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
// parse
|
||||
tag = new string(raw, index, length);
|
||||
index += length;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,15 +5,14 @@
|
|||
<RootNamespace>StardewModdingAPI.Toolkit</RootNamespace>
|
||||
<Description>A library which encapsulates mod-handling logic for mod managers and tools. Not intended for use by mods.</Description>
|
||||
<TargetFrameworks>net4.5;netstandard2.0</TargetFrameworks>
|
||||
<OutputPath>..\..\bin\$(Configuration)\SMAPI.Toolkit</OutputPath>
|
||||
<DocumentationFile>..\..\bin\$(Configuration)\SMAPI.Toolkit\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\SMAPI.Toolkit.xml</DocumentationFile>
|
||||
<PlatformTarget Condition="'$(TargetFramework)' == 'net4.5'">x86</PlatformTarget>
|
||||
<RootNamespace>StardewModdingAPI.Toolkit</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.18" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
|
||||
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
|
||||
<PackageReference Include="System.Management" Version="4.5.0" Condition="'$(OS)' == 'Windows_NT'" />
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using StardewModdingAPI.Toolkit.Framework;
|
||||
|
||||
namespace StardewModdingAPI.Toolkit
|
||||
{
|
||||
|
@ -9,6 +10,8 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// - short-form "x.y" versions are supported (equivalent to "x.y.0");
|
||||
/// - hyphens are synonymous with dots in prerelease tags and build metadata (like "-unofficial.3-pathoschild");
|
||||
/// - and "-unofficial" in prerelease tags is always lower-precedence (e.g. "1.0-beta" is newer than "1.0-unofficial").
|
||||
///
|
||||
/// This optionally also supports four-part versions, a non-standard extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.
|
||||
/// </remarks>
|
||||
public class SemanticVersion : ISemanticVersion
|
||||
{
|
||||
|
@ -16,14 +19,7 @@ namespace StardewModdingAPI.Toolkit
|
|||
** Fields
|
||||
*********/
|
||||
/// <summary>A regex pattern matching a valid prerelease or build metadata tag.</summary>
|
||||
internal const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
|
||||
|
||||
/// <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>" + SemanticVersion.TagPattern + "))?(?:\\+(?<buildmetadata>" + SemanticVersion.TagPattern + "))?";
|
||||
|
||||
/// <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 deviations to support the Stardew Valley mod conventions (see remarks on <see cref="SemanticVersion"/>).</remarks>
|
||||
internal static readonly Regex Regex = new Regex($@"^{SemanticVersion.UnboundedVersionPattern}$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.ExplicitCapture);
|
||||
private const string TagPattern = @"(?>[a-z0-9]+[\-\.]?)+";
|
||||
|
||||
|
||||
/*********
|
||||
|
@ -38,6 +34,9 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <summary>The patch version for backwards-compatible bug fixes.</summary>
|
||||
public int PatchVersion { get; }
|
||||
|
||||
/// <summary>The platform release. This is a non-standard semver extension used by Stardew Valley on ported platforms to represent platform-specific patches to a ported version, represented as a fourth number in the version string.</summary>
|
||||
public int PlatformRelease { get; }
|
||||
|
||||
/// <summary>An optional prerelease tag.</summary>
|
||||
public string PrereleaseTag { get; }
|
||||
|
||||
|
@ -52,13 +51,15 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <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 fixes.</param>
|
||||
/// <param name="platformRelease">The platform-specific version (if applicable).</param>
|
||||
/// <param name="prereleaseTag">An optional prerelease tag.</param>
|
||||
/// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
|
||||
public SemanticVersion(int major, int minor, int patch, string prereleaseTag = null, string buildMetadata = null)
|
||||
public SemanticVersion(int major, int minor, int patch, int platformRelease = 0, string prereleaseTag = null, string buildMetadata = null)
|
||||
{
|
||||
this.MajorVersion = major;
|
||||
this.MinorVersion = minor;
|
||||
this.PatchVersion = patch;
|
||||
this.PlatformRelease = platformRelease;
|
||||
this.PrereleaseTag = this.GetNormalizedTag(prereleaseTag);
|
||||
this.BuildMetadata = this.GetNormalizedTag(buildMetadata);
|
||||
|
||||
|
@ -82,23 +83,22 @@ namespace StardewModdingAPI.Toolkit
|
|||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="version">The semantic version string.</param>
|
||||
/// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</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 SemanticVersion(string version)
|
||||
public SemanticVersion(string version, bool allowNonStandard = false)
|
||||
{
|
||||
// parse
|
||||
if (version == null)
|
||||
throw new ArgumentNullException(nameof(version), "The input version string can't be null.");
|
||||
var match = SemanticVersion.Regex.Match(version.Trim());
|
||||
if (!match.Success)
|
||||
if (!SemanticVersionReader.TryParse(version, allowNonStandard, out int major, out int minor, out int patch, out int platformRelease, out string prereleaseTag, out string buildMetadata) || (!allowNonStandard && platformRelease != 0))
|
||||
throw new FormatException($"The input '{version}' isn't a valid semantic version.");
|
||||
|
||||
// initialize
|
||||
this.MajorVersion = int.Parse(match.Groups["major"].Value);
|
||||
this.MinorVersion = match.Groups["minor"].Success ? int.Parse(match.Groups["minor"].Value) : 0;
|
||||
this.PatchVersion = match.Groups["patch"].Success ? int.Parse(match.Groups["patch"].Value) : 0;
|
||||
this.PrereleaseTag = match.Groups["prerelease"].Success ? this.GetNormalizedTag(match.Groups["prerelease"].Value) : null;
|
||||
this.BuildMetadata = match.Groups["buildmetadata"].Success ? this.GetNormalizedTag(match.Groups["buildmetadata"].Value) : null;
|
||||
this.MajorVersion = major;
|
||||
this.MinorVersion = minor;
|
||||
this.PatchVersion = patch;
|
||||
this.PlatformRelease = platformRelease;
|
||||
this.PrereleaseTag = prereleaseTag;
|
||||
this.BuildMetadata = buildMetadata;
|
||||
|
||||
this.AssertValid();
|
||||
}
|
||||
|
@ -110,7 +110,7 @@ namespace StardewModdingAPI.Toolkit
|
|||
{
|
||||
if (other == null)
|
||||
throw new ArgumentNullException(nameof(other));
|
||||
return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, other.PrereleaseTag);
|
||||
return this.CompareTo(other.MajorVersion, other.MinorVersion, other.PatchVersion, (other as SemanticVersion)?.PlatformRelease ?? 0, other.PrereleaseTag);
|
||||
}
|
||||
|
||||
/// <summary>Indicates whether the current object is equal to another object of the same type.</summary>
|
||||
|
@ -139,7 +139,7 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
|
||||
public bool IsOlderThan(string other)
|
||||
{
|
||||
return this.IsOlderThan(new SemanticVersion(other));
|
||||
return this.IsOlderThan(new SemanticVersion(other, allowNonStandard: true));
|
||||
}
|
||||
|
||||
/// <summary>Get whether this version is newer than the specified version.</summary>
|
||||
|
@ -154,7 +154,7 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <exception cref="FormatException">The specified version is not a valid semantic version.</exception>
|
||||
public bool IsNewerThan(string other)
|
||||
{
|
||||
return this.IsNewerThan(new SemanticVersion(other));
|
||||
return this.IsNewerThan(new SemanticVersion(other, allowNonStandard: true));
|
||||
}
|
||||
|
||||
/// <summary>Get whether this version is between two specified versions (inclusively).</summary>
|
||||
|
@ -171,13 +171,15 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <exception cref="FormatException">One of the specified versions is not a valid semantic version.</exception>
|
||||
public bool IsBetween(string min, string max)
|
||||
{
|
||||
return this.IsBetween(new SemanticVersion(min), new SemanticVersion(max));
|
||||
return this.IsBetween(new SemanticVersion(min, allowNonStandard: true), new SemanticVersion(max, allowNonStandard: true));
|
||||
}
|
||||
|
||||
/// <summary>Get a string representation of the version.</summary>
|
||||
public override string ToString()
|
||||
{
|
||||
string version = $"{this.MajorVersion}.{this.MinorVersion}.{this.PatchVersion}";
|
||||
if (this.PlatformRelease != 0)
|
||||
version += $".{this.PlatformRelease}";
|
||||
if (this.PrereleaseTag != null)
|
||||
version += $"-{this.PrereleaseTag}";
|
||||
if (this.BuildMetadata != null)
|
||||
|
@ -185,15 +187,30 @@ namespace StardewModdingAPI.Toolkit
|
|||
return version;
|
||||
}
|
||||
|
||||
/// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
|
||||
public bool IsNonStandard()
|
||||
{
|
||||
return this.PlatformRelease != 0;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static bool TryParse(string version, out ISemanticVersion parsed)
|
||||
{
|
||||
return SemanticVersion.TryParseNonStandard(version, out parsed) && !parsed.IsNonStandard();
|
||||
}
|
||||
|
||||
/// <summary>Parse a version string without throwing an exception if it fails, including support for non-standard extensions like <see cref="IPlatformSpecificVersion"/>.</summary>
|
||||
/// <param name="version">The version string.</param>
|
||||
/// <param name="parsed">The parsed representation.</param>
|
||||
/// <returns>Returns whether parsing the version succeeded.</returns>
|
||||
public static bool TryParseNonStandard(string version, out ISemanticVersion parsed)
|
||||
{
|
||||
try
|
||||
{
|
||||
parsed = new SemanticVersion(version);
|
||||
parsed = new SemanticVersion(version, true);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
|
@ -219,8 +236,9 @@ namespace StardewModdingAPI.Toolkit
|
|||
/// <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="otherPlatformRelease">The non-standard platform release to compare with this instance.</param>
|
||||
/// <param name="otherTag">The prerelease tag to compare with this instance.</param>
|
||||
private int CompareTo(int otherMajor, int otherMinor, int otherPatch, string otherTag)
|
||||
private int CompareTo(int otherMajor, int otherMinor, int otherPatch, int otherPlatformRelease, string otherTag)
|
||||
{
|
||||
const int same = 0;
|
||||
const int curNewer = 1;
|
||||
|
@ -233,6 +251,8 @@ namespace StardewModdingAPI.Toolkit
|
|||
return this.MinorVersion.CompareTo(otherMinor);
|
||||
if (this.PatchVersion != otherPatch)
|
||||
return this.PatchVersion.CompareTo(otherPatch);
|
||||
if (this.PlatformRelease != otherPlatformRelease)
|
||||
return this.PlatformRelease.CompareTo(otherPlatformRelease);
|
||||
if (this.PrereleaseTag == otherTag)
|
||||
return same;
|
||||
|
||||
|
@ -274,7 +294,7 @@ namespace StardewModdingAPI.Toolkit
|
|||
}
|
||||
|
||||
// fallback (this should never happen)
|
||||
return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
|
||||
return string.Compare(this.ToString(), new SemanticVersion(otherMajor, otherMinor, otherPatch, otherPlatformRelease, otherTag).ToString(), StringComparison.InvariantCultureIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Assert that the current version is valid.</summary>
|
||||
|
|
|
@ -68,7 +68,7 @@ namespace StardewModdingAPI.Toolkit.Serialization.Converters
|
|||
int patch = obj.ValueIgnoreCase<int>(nameof(ISemanticVersion.PatchVersion));
|
||||
string prereleaseTag = obj.ValueIgnoreCase<string>(nameof(ISemanticVersion.PrereleaseTag));
|
||||
|
||||
return new SemanticVersion(major, minor, patch, prereleaseTag);
|
||||
return new SemanticVersion(major, minor, patch, prereleaseTag: prereleaseTag);
|
||||
}
|
||||
|
||||
/// <summary>Read a JSON string.</summary>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="3.0.2" />
|
||||
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
@ -94,8 +94,6 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
if (model?.Mods == null)
|
||||
return new ModEntryModel[0];
|
||||
|
||||
bool legacyMode = SemanticVersion.TryParse(version, out ISemanticVersion parsedVersion) && parsedVersion.IsOlderThan("3.0.0-beta.20191109");
|
||||
|
||||
// fetch wiki data
|
||||
WikiModEntry[] wikiData = this.WikiCache.GetWikiMods().Select(p => p.GetModel()).ToArray();
|
||||
IDictionary<string, ModEntryModel> mods = new Dictionary<string, ModEntryModel>(StringComparer.CurrentCultureIgnoreCase);
|
||||
|
@ -104,19 +102,8 @@ namespace StardewModdingAPI.Web.Controllers
|
|||
if (string.IsNullOrWhiteSpace(mod.ID))
|
||||
continue;
|
||||
|
||||
ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata || legacyMode, model.ApiVersion);
|
||||
if (legacyMode)
|
||||
{
|
||||
result.Main = result.Metadata.Main;
|
||||
result.Optional = result.Metadata.Optional;
|
||||
result.Unofficial = result.Metadata.Unofficial;
|
||||
result.UnofficialForBeta = result.Metadata.UnofficialForBeta;
|
||||
result.HasBetaInfo = result.Metadata.BetaCompatibilityStatus != null;
|
||||
result.SuggestedUpdate = null;
|
||||
if (!model.IncludeExtendedMetadata)
|
||||
result.Metadata = null;
|
||||
}
|
||||
else if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null))
|
||||
ModEntryModel result = await this.GetModData(mod, wikiData, model.IncludeExtendedMetadata, model.ApiVersion);
|
||||
if (!model.IncludeExtendedMetadata && (model.ApiVersion == null || mod.InstalledVersion == null))
|
||||
{
|
||||
var errors = new List<string>(result.Errors);
|
||||
errors.Add($"This API can't suggest an update because {nameof(model.ApiVersion)} or {nameof(mod.InstalledVersion)} are null, and you didn't specify {nameof(model.IncludeExtendedMetadata)} to get other info. See the SMAPI technical docs for usage.");
|
||||
|
|
|
@ -67,8 +67,8 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
|
|||
/// <summary>The human-readable warnings for players about this mod.</summary>
|
||||
public string[] Warnings { get; set; }
|
||||
|
||||
/// <summary>Extra metadata links (usually for open pull requests).</summary>
|
||||
public Tuple<Uri, string>[] MetadataLinks { get; set; }
|
||||
/// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
|
||||
public string PullRequestUrl { get; set; }
|
||||
|
||||
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
|
||||
public string DevNote { get; set; }
|
||||
|
@ -150,7 +150,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
|
|||
this.CustomSourceUrl = mod.CustomSourceUrl;
|
||||
this.CustomUrl = mod.CustomUrl;
|
||||
this.ContentPackFor = mod.ContentPackFor;
|
||||
this.MetadataLinks = mod.MetadataLinks;
|
||||
this.PullRequestUrl = mod.PullRequestUrl;
|
||||
this.Warnings = mod.Warnings;
|
||||
this.DevNote = mod.DevNote;
|
||||
this.Anchor = mod.Anchor;
|
||||
|
@ -192,7 +192,7 @@ namespace StardewModdingAPI.Web.Framework.Caching.Wiki
|
|||
CustomUrl = this.CustomUrl,
|
||||
ContentPackFor = this.ContentPackFor,
|
||||
Warnings = this.Warnings,
|
||||
MetadataLinks = this.MetadataLinks,
|
||||
PullRequestUrl = this.PullRequestUrl,
|
||||
DevNote = this.DevNote,
|
||||
Anchor = this.Anchor,
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@ namespace StardewModdingAPI.Web.Framework.Clients.Chucklefish
|
|||
string name = doc.DocumentNode.SelectSingleNode("//meta[@name='twitter:title']").Attributes["content"].Value;
|
||||
if (name.StartsWith("[SMAPI] "))
|
||||
name = name.Substring("[SMAPI] ".Length);
|
||||
string version = doc.DocumentNode.SelectSingleNode("//h1/span").InnerText;
|
||||
string version = doc.DocumentNode.SelectSingleNode("//h1/span")?.InnerText;
|
||||
|
||||
// create model
|
||||
return new ChucklefishMod
|
||||
|
|
|
@ -3,7 +3,6 @@ using System.Collections.Generic;
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using StardewModdingAPI.Toolkit;
|
||||
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
||||
|
||||
namespace StardewModdingAPI.Web.Framework.LogParsing
|
||||
|
@ -31,22 +30,22 @@ 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>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))?(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private readonly Regex ModListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: 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>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+)(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary>
|
||||
private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>A regex pattern matching an entry in SMAPI's mod update list.</summary>
|
||||
private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private readonly Regex ModUpdateListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
/// <summary>A regex pattern matching SMAPI's update line.</summary>
|
||||
private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>" + SemanticVersion.UnboundedVersionPattern + @"): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private readonly Regex SMAPIUpdatePattern = new Regex(@"^You can update SMAPI to (?<version>[^\s]+): (?<link>.+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
|
||||
/*********
|
||||
|
|
|
@ -1,16 +1,34 @@
|
|||
using Microsoft.AspNetCore.Routing.Constraints;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using StardewModdingAPI.Toolkit;
|
||||
|
||||
namespace StardewModdingAPI.Web.Framework
|
||||
{
|
||||
/// <summary>Constrains a route value to a valid semantic version.</summary>
|
||||
internal class VersionConstraint : RegexRouteConstraint
|
||||
internal class VersionConstraint : IRouteConstraint
|
||||
{
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
public VersionConstraint()
|
||||
: base(SemanticVersion.Regex) { }
|
||||
/// <summary>Get whether the URL parameter contains a valid value for this constraint.</summary>
|
||||
/// <param name="httpContext">An object that encapsulates information about the HTTP request.</param>
|
||||
/// <param name="route">The router that this constraint belongs to.</param>
|
||||
/// <param name="routeKey">The name of the parameter that is being checked.</param>
|
||||
/// <param name="values">A dictionary that contains the parameters for the URL.</param>
|
||||
/// <param name="routeDirection">An object that indicates whether the constraint check is being performed when an incoming request is being handled or when a URL is being generated.</param>
|
||||
/// <returns><c>true</c> if the URL parameter contains a valid value; otherwise, <c>false</c>.</returns>
|
||||
public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
|
||||
{
|
||||
if (routeKey == null)
|
||||
throw new ArgumentNullException(nameof(routeKey));
|
||||
if (values == null)
|
||||
throw new ArgumentNullException(nameof(values));
|
||||
|
||||
return
|
||||
values.TryGetValue(routeKey, out object routeValue)
|
||||
&& routeValue is string routeStr
|
||||
&& SemanticVersion.TryParseNonStandard(routeStr, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,21 +12,21 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.1.0" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" />
|
||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.2.0" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.9" />
|
||||
<PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" />
|
||||
<PackageReference Include="Hangfire.Mongo" Version="0.6.5" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
|
||||
<PackageReference Include="Hangfire.Mongo" Version="0.6.6" />
|
||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.18" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
|
||||
<PackageReference Include="JetBrains.Annotations" Version="2019.1.3" />
|
||||
<PackageReference Include="Markdig" Version="0.18.0" />
|
||||
<PackageReference Include="Markdig" Version="0.18.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
|
||||
<PackageReference Include="Mongo2Go" Version="2.2.12" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.9.3" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
|
||||
<PackageReference Include="MongoDB.Driver" Version="2.10.1" />
|
||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.13" />
|
||||
<PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
|
||||
<PackageReference Include="Pathoschild.Http.FluentClient" Version="3.3.1" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StardewModdingAPI.Toolkit.Framework.Clients.Wiki;
|
||||
|
@ -38,8 +37,8 @@ namespace StardewModdingAPI.Web.ViewModels
|
|||
/// <summary>The human-readable warnings for players about this mod.</summary>
|
||||
public string[] Warnings { get; set; }
|
||||
|
||||
/// <summary>Extra metadata links (usually for open pull requests).</summary>
|
||||
public Tuple<Uri, string>[] MetadataLinks { get; set; }
|
||||
/// <summary>The URL of the pull request which submits changes for an unofficial update to the author, if any.</summary>
|
||||
public string PullRequestUrl { get; set; }
|
||||
|
||||
/// <summary>Special notes intended for developers who maintain unofficial updates or submit pull requests. </summary>
|
||||
public string DevNote { get; set; }
|
||||
|
@ -68,7 +67,7 @@ namespace StardewModdingAPI.Web.ViewModels
|
|||
this.BetaCompatibility = entry.BetaCompatibility != null ? new ModCompatibilityModel(entry.BetaCompatibility) : null;
|
||||
this.ModPages = this.GetModPageUrls(entry).ToArray();
|
||||
this.Warnings = entry.Warnings;
|
||||
this.MetadataLinks = entry.MetadataLinks;
|
||||
this.PullRequestUrl = entry.PullRequestUrl;
|
||||
this.DevNote = entry.DevNote;
|
||||
this.Slug = entry.Anchor;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
@using Markdig
|
||||
@using Microsoft.Extensions.Options
|
||||
@using StardewModdingAPI.Web.Framework
|
||||
@using StardewModdingAPI.Web.Framework.ConfigModels
|
||||
|
@ -6,18 +5,22 @@
|
|||
@model StardewModdingAPI.Web.ViewModels.IndexModel
|
||||
@{
|
||||
ViewData["Title"] = "SMAPI";
|
||||
ViewData["ViewTitle"] = string.Empty;
|
||||
}
|
||||
@section Head {
|
||||
<link rel="stylesheet" href="~/Content/css/index.css?r=20190620" />
|
||||
<link rel="stylesheet" href="~/Content/css/index.css?r=20200105" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
|
||||
<script src="~/Content/js/index.js?r=20190620"></script>
|
||||
<script src="~/Content/js/index.js?r=20200105"></script>
|
||||
}
|
||||
|
||||
<p id="blurb">
|
||||
The mod loader for Stardew Valley. It works fine with GOG and Steam achievements, it's
|
||||
compatible with Linux/Mac/Windows, you can uninstall it anytime, and there's a friendly
|
||||
community if you need help. It's a cool pufferchick.
|
||||
</p>
|
||||
<h1>
|
||||
SMAPI
|
||||
<img id="pufferchick" src="Content/images/pufferchick.png" />
|
||||
</h1>
|
||||
<div id="blurb">
|
||||
<p>The mod loader for Stardew Valley.</p>
|
||||
<p>Compatible with GOG/Steam achievements and Linux/Mac/Windows, uninstall anytime, and there's a friendly community if you need help.</p>
|
||||
</div>
|
||||
|
||||
<div id="call-to-action">
|
||||
<div class="cta-dropdown">
|
||||
|
@ -45,80 +48,84 @@
|
|||
</div><br />
|
||||
}
|
||||
<div><a href="https://stardewvalleywiki.com/Modding:Player_Guide" class="secondary-cta">Player guide</a></div>
|
||||
<div class="sublinks">
|
||||
<a href="https://github.com/Pathoschild/SMAPI">source code</a> | <a href="@Url.PlainAction("Privacy", "Index")">privacy</a>
|
||||
</div>
|
||||
<img id="pufferchick" src="Content/images/pufferchick.png" />
|
||||
</div>
|
||||
|
||||
<h2 id="help">Get help</h2>
|
||||
<ul>
|
||||
<li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li>
|
||||
<li>Get help <a href="https://smapi.io/community">on Discord or in the forums</a></li>
|
||||
</ul>
|
||||
<div class="area">
|
||||
<h2 id="help">Get help</h2>
|
||||
<ul>
|
||||
<li><a href="https://smapi.io/community">Ask on Discord</a></li>
|
||||
<li><a href="https://reddit.com/r/SMAPI">Ask on Reddit</a></li>
|
||||
<li><a href="@Url.PlainAction("Index", "Mods")">Mod compatibility list</a></li>
|
||||
</ul>
|
||||
(Or join the community!)
|
||||
</div>
|
||||
|
||||
@if (Model.BetaVersion == null)
|
||||
{
|
||||
<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>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<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))
|
||||
</div>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
|
||||
<h3>SMAPI @Model.BetaVersion.Version?</h3>
|
||||
<div class="github-description">
|
||||
@Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description))
|
||||
</div>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
}
|
||||
|
||||
<h2 id="donate">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.
|
||||
</p>
|
||||
|
||||
<ul id="donate-links">
|
||||
<li>
|
||||
<a href="https://www.patreon.com/pathoschild" class="donate-button">
|
||||
<img src="Content/images/patreon.png" /> Become a patron
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://ko-fi.com/pathoschild" class="donate-button">
|
||||
<img src="Content/images/ko-fi.png" /> Buy me a coffee
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.paypal.me/pathoschild" class="donate-button">
|
||||
<img src="Content/images/paypal.png" /> Donate via PayPal
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.SupporterList))
|
||||
{
|
||||
@Html.Raw(Markdig.Markdown.ToHtml(
|
||||
$"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!"
|
||||
))
|
||||
}
|
||||
|
||||
<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)
|
||||
<div class="area">
|
||||
@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>
|
||||
<h2 id="whatsnew">What's new</h2>
|
||||
<div class="github-description">
|
||||
@Html.Raw(Markdig.Markdown.ToHtml(Model.StableVersion.Description))
|
||||
</div>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
}
|
||||
<li><a href="https://stardewvalleywiki.com/Modding:Index">Modding documentation</a></li>
|
||||
<li>Need help? Come <a href="https://smapi.io/community">chat on Discord</a>.</li>
|
||||
</ul>
|
||||
else
|
||||
{
|
||||
<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))
|
||||
</div>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
|
||||
<h3>SMAPI @Model.BetaVersion.Version?</h3>
|
||||
<div class="github-description">
|
||||
@Html.Raw(Markdig.Markdown.ToHtml(Model.BetaVersion.Description))
|
||||
</div>
|
||||
<p>See the <a href="https://github.com/Pathoschild/SMAPI/blob/develop/docs/release-notes.md#release-notes">release notes</a> and <a href="@Url.PlainAction("Index", "Mods")">mod compatibility list</a> for more info.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="area">
|
||||
<h2 id="donate">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.
|
||||
</p>
|
||||
|
||||
<ul id="donate-links">
|
||||
<li>
|
||||
<a href="https://www.patreon.com/pathoschild" class="donate-button">
|
||||
<img src="Content/images/patreon.png" /> Become a patron
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://ko-fi.com/pathoschild" class="donate-button">
|
||||
<img src="Content/images/ko-fi.png" /> Buy me a coffee
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://www.paypal.me/pathoschild" class="donate-button">
|
||||
<img src="Content/images/paypal.png" /> Donate via PayPal
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.SupporterList))
|
||||
{
|
||||
@Html.Raw(Markdig.Markdown.ToHtml(
|
||||
$"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!"
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<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><a href="https://github.com/Pathoschild/SMAPI">Source code</a></li>
|
||||
</ul>
|
||||
|
|
|
@ -110,11 +110,8 @@ else
|
|||
<small>
|
||||
<a v-bind:href="'#' + mod.Slug">#</a>
|
||||
<span v-show="showAdvanced">
|
||||
<template v-for="(link, i) in mod.MetadataLinks">
|
||||
<a v-bind:href="link.Item1">{{link.Item2}}</a>
|
||||
</template>
|
||||
|
||||
<abbr v-bind:title="mod.DevNote" v-show="mod.DevNote">[dev note]</abbr>
|
||||
<a v-bind:href="mod.PullRequestUrl" v-if="mod.PullRequestUrl">PR</a>
|
||||
<abbr v-bind:title="mod.DevNote" v-if="mod.DevNote">[dev note]</abbr>
|
||||
</span>
|
||||
</small>
|
||||
</td>
|
||||
|
|
|
@ -29,12 +29,15 @@
|
|||
</div>
|
||||
<div id="content-column">
|
||||
<div id="content">
|
||||
<h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1>
|
||||
@if (ViewData["ViewTitle"] != string.Empty)
|
||||
{
|
||||
<h1>@(ViewData["ViewTitle"] ?? ViewData["Title"])</h1>
|
||||
}
|
||||
@RenderBody()
|
||||
</div>
|
||||
<div id="footer">
|
||||
<div id="license">
|
||||
Hi! You can <a href="https://github.com/pathoschild/SMAPI" title="view source">view the source code</a> or <a href="https://github.com/pathoschild/SMAPI/issues" title="report issue">report a bug or suggestion</a>.
|
||||
Hi! See the <a href="@Url.PlainAction("Privacy", "Index")">privacy page</a>, <a href="https://github.com/pathoschild/SMAPI" title="view source">SMAPI's source code</a>, or <a href="https://smapi.io/community" title="community pages">ask questions</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -21,12 +21,10 @@ h1 {
|
|||
#call-to-action a.main-cta,
|
||||
#call-to-action a.secondary-cta {
|
||||
box-shadow: #caefab 0 1px 0 0 inset;
|
||||
background: linear-gradient(#77d42a 5%, #5cb811 100%) #77d42a;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #268a16;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
color: #306108;
|
||||
font-weight: bold;
|
||||
margin-bottom: 1em;
|
||||
padding: 6px 24px;
|
||||
|
@ -34,10 +32,16 @@ h1 {
|
|||
text-shadow: #aade7c 0 1px 0;
|
||||
}
|
||||
|
||||
#call-to-action a.main-cta {
|
||||
background: linear-gradient(#77d42a 5%, #5cb811 75%) #77d42a;
|
||||
font-size: 1.5em;
|
||||
color: #306108;
|
||||
}
|
||||
|
||||
#call-to-action a.secondary-cta {
|
||||
background: #768d87;
|
||||
border: 1px solid #566963;
|
||||
color: #ffffff;
|
||||
color: #eee;
|
||||
text-shadow: #2b665e 0 1px 0;
|
||||
}
|
||||
|
||||
|
@ -101,9 +105,24 @@ h1 {
|
|||
/*********
|
||||
** Subsections
|
||||
*********/
|
||||
.github-description {
|
||||
border-left: 0.25em solid #dfe2e5;
|
||||
padding-left: 1em;
|
||||
.area {
|
||||
background: rgba(0, 170, 0, 0.2);
|
||||
padding: 0 1em 1em 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.area > ul,
|
||||
.area > div,
|
||||
.area > p {
|
||||
margin-left: 3em;
|
||||
}
|
||||
|
||||
.area > ul {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.area > h2 {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
#donate-links li {
|
||||
|
@ -114,12 +133,12 @@ h1 {
|
|||
#donate-links .donate-button {
|
||||
display: inline-block;
|
||||
min-width: 10em;
|
||||
background: #2A413B;
|
||||
background: #2a413b;
|
||||
padding: 6px 12px;
|
||||
font-family: Quicksand, Helvetica, Century Gothic, sans-serif;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
color: #FFF;
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
|
|
@ -20,13 +20,13 @@ namespace StardewModdingAPI
|
|||
** Public
|
||||
****/
|
||||
/// <summary>SMAPI's current semantic version.</summary>
|
||||
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0");
|
||||
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.2.0");
|
||||
|
||||
/// <summary>Android SMAPI's current semantic version.</summary>
|
||||
public static ISemanticVersion AndroidApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0-experimental");
|
||||
|
||||
/// <summary>The minimum supported version of Stardew Valley.</summary>
|
||||
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0");
|
||||
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.1");
|
||||
|
||||
/// <summary>The maximum supported version of Stardew Valley.</summary>
|
||||
public static ISemanticVersion MaximumGameVersion { get; } = null;
|
||||
|
@ -58,12 +58,18 @@ namespace StardewModdingAPI
|
|||
/// <summary>The URL of the SMAPI home page.</summary>
|
||||
internal const string HomePageUrl = "https://github.com/MartyrPher/SMAPI-Android-Installer/releases/latest";
|
||||
|
||||
/// <summary>The default performance counter name for unknown event handlers.</summary>
|
||||
internal const string GamePerformanceCounterName = "<StardewValley>";
|
||||
|
||||
/// <summary>The absolute path to the folder containing SMAPI's internal files.</summary>
|
||||
internal static readonly string InternalFilesPath = Program.DllSearchPath;
|
||||
|
||||
/// <summary>The file path for the SMAPI configuration file.</summary>
|
||||
internal static string ApiConfigPath => Path.Combine(Constants.InternalFilesPath, "config.json");
|
||||
|
||||
/// <summary>The file path for the overrides file for <see cref="ApiConfigPath"/>, which is applied over it.</summary>
|
||||
internal static string ApiUserConfigPath => Path.Combine(Constants.InternalFilesPath, "config.user.json");
|
||||
|
||||
/// <summary>The file path for the SMAPI metadata file.</summary>
|
||||
internal static string ApiMetadataPath => Path.Combine(Constants.InternalFilesPath, "metadata.json");
|
||||
|
||||
|
|
|
@ -4,7 +4,6 @@ using System.Diagnostics.Contracts;
|
|||
using System.Linq;
|
||||
using Microsoft.Xna.Framework;
|
||||
using StardewModdingAPI.Framework.Reflection;
|
||||
using StardewModdingAPI.Internal;
|
||||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
using StardewValley;
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.Xna.Framework.Content;
|
||||
using StardewModdingAPI.Framework.Content;
|
||||
using StardewModdingAPI.Framework.ContentManagers;
|
||||
|
@ -48,6 +50,10 @@ namespace StardewModdingAPI.Framework
|
|||
/// <summary>Whether the content coordinator has been disposed.</summary>
|
||||
private bool IsDisposed;
|
||||
|
||||
/// <summary>A lock used to prevent asynchronous changes to the content manager list.</summary>
|
||||
/// <remarks>The game may adds content managers in asynchronous threads (e.g. when populating the load screen).</remarks>
|
||||
private readonly ReaderWriterLockSlim ContentManagerLock = new ReaderWriterLockSlim();
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
|
@ -59,10 +65,10 @@ namespace StardewModdingAPI.Framework
|
|||
public LocalizedContentManager.LanguageCode Language => this.MainContentManager.Language;
|
||||
|
||||
/// <summary>Interceptors which provide the initial versions of matching assets.</summary>
|
||||
public IDictionary<IModMetadata, IList<IAssetLoader>> Loaders { get; } = new Dictionary<IModMetadata, IList<IAssetLoader>>();
|
||||
public IList<ModLinked<IAssetLoader>> Loaders { get; } = new List<ModLinked<IAssetLoader>>();
|
||||
|
||||
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
|
||||
public IDictionary<IModMetadata, IList<IAssetEditor>> Editors { get; } = new Dictionary<IModMetadata, IList<IAssetEditor>>();
|
||||
public IList<ModLinked<IAssetEditor>> Editors { get; } = new List<ModLinked<IAssetEditor>>();
|
||||
|
||||
/// <summary>The absolute path to the <see cref="ContentManager.RootDirectory"/>.</summary>
|
||||
public string FullRootDirectory { get; }
|
||||
|
@ -96,9 +102,12 @@ namespace StardewModdingAPI.Framework
|
|||
/// <param name="name">A name for the mod manager. Not guaranteed to be unique.</param>
|
||||
public GameContentManager CreateGameContentManager(string name)
|
||||
{
|
||||
GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
|
||||
this.ContentManagers.Add(manager);
|
||||
return manager;
|
||||
return this.ContentManagerLock.InWriteLock(() =>
|
||||
{
|
||||
GameContentManager manager = new GameContentManager(name, this.MainContentManager.ServiceProvider, this.MainContentManager.RootDirectory, this.MainContentManager.CurrentCulture, this, this.Monitor, this.Reflection, this.OnDisposing, this.OnLoadingFirstAsset);
|
||||
this.ContentManagers.Add(manager);
|
||||
return manager;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Get a new content manager which handles reading files from a SMAPI mod folder with support for unpacked files.</summary>
|
||||
|
@ -107,20 +116,23 @@ namespace StardewModdingAPI.Framework
|
|||
/// <param name="gameContentManager">The game content manager used for map tilesheets not provided by the mod.</param>
|
||||
public ModContentManager CreateModContentManager(string name, string rootDirectory, IContentManager gameContentManager)
|
||||
{
|
||||
ModContentManager manager = new ModContentManager(
|
||||
name: name,
|
||||
gameContentManager: gameContentManager,
|
||||
serviceProvider: this.MainContentManager.ServiceProvider,
|
||||
rootDirectory: rootDirectory,
|
||||
currentCulture: this.MainContentManager.CurrentCulture,
|
||||
coordinator: this,
|
||||
monitor: this.Monitor,
|
||||
reflection: this.Reflection,
|
||||
jsonHelper: this.JsonHelper,
|
||||
onDisposing: this.OnDisposing
|
||||
);
|
||||
this.ContentManagers.Add(manager);
|
||||
return manager;
|
||||
return this.ContentManagerLock.InWriteLock(() =>
|
||||
{
|
||||
ModContentManager manager = new ModContentManager(
|
||||
name: name,
|
||||
gameContentManager: gameContentManager,
|
||||
serviceProvider: this.MainContentManager.ServiceProvider,
|
||||
rootDirectory: rootDirectory,
|
||||
currentCulture: this.MainContentManager.CurrentCulture,
|
||||
coordinator: this,
|
||||
monitor: this.Monitor,
|
||||
reflection: this.Reflection,
|
||||
jsonHelper: this.JsonHelper,
|
||||
onDisposing: this.OnDisposing
|
||||
);
|
||||
this.ContentManagers.Add(manager);
|
||||
return manager;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Get the current content locale.</summary>
|
||||
|
@ -132,8 +144,11 @@ namespace StardewModdingAPI.Framework
|
|||
/// <summary>Perform any cleanup needed when the locale changes.</summary>
|
||||
public void OnLocaleChanged()
|
||||
{
|
||||
foreach (IContentManager contentManager in this.ContentManagers)
|
||||
contentManager.OnLocaleChanged();
|
||||
this.ContentManagerLock.InReadLock(() =>
|
||||
{
|
||||
foreach (IContentManager contentManager in this.ContentManagers)
|
||||
contentManager.OnLocaleChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Get whether this asset is mapped to a mod folder.</summary>
|
||||
|
@ -180,7 +195,9 @@ namespace StardewModdingAPI.Framework
|
|||
public T LoadManagedAsset<T>(string contentManagerID, string relativePath)
|
||||
{
|
||||
// get content manager
|
||||
IContentManager contentManager = this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID);
|
||||
IContentManager contentManager = this.ContentManagerLock.InReadLock(() =>
|
||||
this.ContentManagers.FirstOrDefault(p => p.IsNamespaced && p.Name == contentManagerID)
|
||||
);
|
||||
if (contentManager == null)
|
||||
throw new InvalidOperationException($"The '{contentManagerID}' prefix isn't handled by any mod.");
|
||||
|
||||
|
@ -210,15 +227,18 @@ namespace StardewModdingAPI.Framework
|
|||
{
|
||||
// invalidate cache & track removed assets
|
||||
IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
|
||||
foreach (IContentManager contentManager in this.ContentManagers)
|
||||
this.ContentManagerLock.InReadLock(() =>
|
||||
{
|
||||
foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
|
||||
foreach (IContentManager contentManager in this.ContentManagers)
|
||||
{
|
||||
if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
|
||||
removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
|
||||
assets.Add(entry.Value);
|
||||
foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
|
||||
{
|
||||
if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
|
||||
removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
|
||||
assets.Add(entry.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// reload core game assets
|
||||
if (removedAssets.Any())
|
||||
|
@ -232,6 +252,23 @@ namespace StardewModdingAPI.Framework
|
|||
return removedAssets.Keys;
|
||||
}
|
||||
|
||||
/// <summary>Get all loaded instances of an asset name.</summary>
|
||||
/// <param name="assetName">The asset name.</param>
|
||||
[SuppressMessage("ReSharper", "UnusedMember.Global", Justification = "This method is provided for Content Patcher.")]
|
||||
public IEnumerable<object> GetLoadedValues(string assetName)
|
||||
{
|
||||
return this.ContentManagerLock.InReadLock(() =>
|
||||
{
|
||||
List<object> values = new List<object>();
|
||||
foreach (IContentManager content in this.ContentManagers.Where(p => !p.IsNamespaced && p.IsLoaded(assetName)))
|
||||
{
|
||||
object value = content.Load<object>(assetName, this.Language, useCache: true);
|
||||
values.Add(value);
|
||||
}
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Dispose held resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
|
@ -244,6 +281,8 @@ namespace StardewModdingAPI.Framework
|
|||
contentManager.Dispose();
|
||||
this.ContentManagers.Clear();
|
||||
this.MainContentManager = null;
|
||||
|
||||
this.ContentManagerLock.Dispose();
|
||||
}
|
||||
|
||||
|
||||
|
@ -257,7 +296,9 @@ namespace StardewModdingAPI.Framework
|
|||
if (this.IsDisposed)
|
||||
return;
|
||||
|
||||
this.ContentManagers.Remove(contentManager);
|
||||
this.ContentManagerLock.InWriteLock(() =>
|
||||
this.ContentManagers.Remove(contentManager)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,10 +21,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
private readonly ContextHash<string> AssetsBeingLoaded = new ContextHash<string>();
|
||||
|
||||
/// <summary>Interceptors which provide the initial versions of matching assets.</summary>
|
||||
private IDictionary<IModMetadata, IList<IAssetLoader>> Loaders => this.Coordinator.Loaders;
|
||||
private IList<ModLinked<IAssetLoader>> Loaders => this.Coordinator.Loaders;
|
||||
|
||||
/// <summary>Interceptors which edit matching assets after they're loaded.</summary>
|
||||
private IDictionary<IModMetadata, IList<IAssetEditor>> Editors => this.Coordinator.Editors;
|
||||
private IList<ModLinked<IAssetEditor>> Editors => this.Coordinator.Editors;
|
||||
|
||||
/// <summary>A lookup which indicates whether the asset is localizable (i.e. the filename contains the locale), if previously loaded.</summary>
|
||||
private readonly IDictionary<string, bool> IsLocalizableLookup;
|
||||
|
@ -278,16 +278,16 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
private IAssetData ApplyLoader<T>(IAssetInfo info)
|
||||
{
|
||||
// find matching loaders
|
||||
var loaders = this.GetInterceptors(this.Loaders)
|
||||
var loaders = this.Loaders
|
||||
.Where(entry =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return entry.Value.CanLoad<T>(info);
|
||||
return entry.Data.CanLoad<T>(info);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
entry.Key.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
||||
entry.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{info.AssetName}', and will be ignored. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
||||
return false;
|
||||
}
|
||||
})
|
||||
|
@ -298,14 +298,14 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
return null;
|
||||
if (loaders.Length > 1)
|
||||
{
|
||||
string[] loaderNames = loaders.Select(p => p.Key.DisplayName).ToArray();
|
||||
string[] loaderNames = loaders.Select(p => p.Mod.DisplayName).ToArray();
|
||||
this.Monitor.Log($"Multiple mods want to provide the '{info.AssetName}' asset ({string.Join(", ", loaderNames)}), but an asset can't be loaded multiple times. SMAPI will use the default asset instead; uninstall one of the mods to fix this. (Message for modders: you should usually use {typeof(IAssetEditor)} instead to avoid conflicts.)", LogLevel.Warn);
|
||||
return null;
|
||||
}
|
||||
|
||||
// fetch asset from loader
|
||||
IModMetadata mod = loaders[0].Key;
|
||||
IAssetLoader loader = loaders[0].Value;
|
||||
IModMetadata mod = loaders[0].Mod;
|
||||
IAssetLoader loader = loaders[0].Data;
|
||||
T data;
|
||||
try
|
||||
{
|
||||
|
@ -338,11 +338,11 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
IAssetData GetNewData(object data) => new AssetDataForObject(info, data, this.AssertAndNormalizeAssetName);
|
||||
|
||||
// edit asset
|
||||
foreach (var entry in this.GetInterceptors(this.Editors))
|
||||
foreach (var entry in this.Editors)
|
||||
{
|
||||
// check for match
|
||||
IModMetadata mod = entry.Key;
|
||||
IAssetEditor editor = entry.Value;
|
||||
IModMetadata mod = entry.Mod;
|
||||
IAssetEditor editor = entry.Data;
|
||||
try
|
||||
{
|
||||
if (!editor.CanEdit<T>(info))
|
||||
|
@ -382,19 +382,5 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
// return result
|
||||
return asset;
|
||||
}
|
||||
|
||||
/// <summary>Get all registered interceptors from a list.</summary>
|
||||
private IEnumerable<KeyValuePair<IModMetadata, T>> GetInterceptors<T>(IDictionary<IModMetadata, IList<T>> entries)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
IModMetadata mod = entry.Key;
|
||||
IList<T> interceptors = entry.Value;
|
||||
|
||||
// registered editors
|
||||
foreach (T interceptor in interceptors)
|
||||
yield return new KeyValuePair<IModMetadata, T>(mod, interceptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -160,6 +160,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
|||
|
||||
// unpacked map
|
||||
case ".tbin":
|
||||
case ".tmx":
|
||||
{
|
||||
// validate
|
||||
if (typeof(T) != typeof(Map))
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using StardewModdingAPI.Events;
|
||||
using StardewModdingAPI.Framework.PerformanceMonitoring;
|
||||
|
||||
namespace StardewModdingAPI.Framework.Events
|
||||
{
|
||||
|
@ -173,28 +176,32 @@ namespace StardewModdingAPI.Framework.Events
|
|||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="monitor">Writes messages to the log.</param>
|
||||
/// <param name="modRegistry">The mod registry with which to identify mods.</param>
|
||||
public EventManager(IMonitor monitor, ModRegistry modRegistry)
|
||||
/// <param name="performanceMonitor">Tracks performance metrics.</param>
|
||||
public EventManager(IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor)
|
||||
{
|
||||
// create shortcut initializers
|
||||
ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName) => new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry);
|
||||
ManagedEvent<TEventArgs> ManageEventOf<TEventArgs>(string typeName, string eventName, bool isPerformanceCritical = false)
|
||||
{
|
||||
return new ManagedEvent<TEventArgs>($"{typeName}.{eventName}", monitor, modRegistry, performanceMonitor, isPerformanceCritical);
|
||||
}
|
||||
|
||||
// init events (new)
|
||||
this.MenuChanged = ManageEventOf<MenuChangedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.MenuChanged));
|
||||
this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering));
|
||||
this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered));
|
||||
this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld));
|
||||
this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld));
|
||||
this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu));
|
||||
this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu));
|
||||
this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud));
|
||||
this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud));
|
||||
this.Rendering = ManageEventOf<RenderingEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendering), isPerformanceCritical: true);
|
||||
this.Rendered = ManageEventOf<RenderedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.Rendered), isPerformanceCritical: true);
|
||||
this.RenderingWorld = ManageEventOf<RenderingWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingWorld), isPerformanceCritical: true);
|
||||
this.RenderedWorld = ManageEventOf<RenderedWorldEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedWorld), isPerformanceCritical: true);
|
||||
this.RenderingActiveMenu = ManageEventOf<RenderingActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingActiveMenu), isPerformanceCritical: true);
|
||||
this.RenderedActiveMenu = ManageEventOf<RenderedActiveMenuEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedActiveMenu), isPerformanceCritical: true);
|
||||
this.RenderingHud = ManageEventOf<RenderingHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderingHud), isPerformanceCritical: true);
|
||||
this.RenderedHud = ManageEventOf<RenderedHudEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.RenderedHud), isPerformanceCritical: true);
|
||||
this.WindowResized = ManageEventOf<WindowResizedEventArgs>(nameof(IModEvents.Display), nameof(IDisplayEvents.WindowResized));
|
||||
|
||||
this.GameLaunched = ManageEventOf<GameLaunchedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.GameLaunched));
|
||||
this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking));
|
||||
this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked));
|
||||
this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking));
|
||||
this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked));
|
||||
this.UpdateTicking = ManageEventOf<UpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicking), isPerformanceCritical: true);
|
||||
this.UpdateTicked = ManageEventOf<UpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.UpdateTicked), isPerformanceCritical: true);
|
||||
this.OneSecondUpdateTicking = ManageEventOf<OneSecondUpdateTickingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicking), isPerformanceCritical: true);
|
||||
this.OneSecondUpdateTicked = ManageEventOf<OneSecondUpdateTickedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.OneSecondUpdateTicked), isPerformanceCritical: true);
|
||||
this.SaveCreating = ManageEventOf<SaveCreatingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreating));
|
||||
this.SaveCreated = ManageEventOf<SaveCreatedEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.SaveCreated));
|
||||
this.Saving = ManageEventOf<SavingEventArgs>(nameof(IModEvents.GameLoop), nameof(IGameLoopEvents.Saving));
|
||||
|
@ -207,7 +214,7 @@ namespace StardewModdingAPI.Framework.Events
|
|||
|
||||
this.ButtonPressed = ManageEventOf<ButtonPressedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonPressed));
|
||||
this.ButtonReleased = ManageEventOf<ButtonReleasedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.ButtonReleased));
|
||||
this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved));
|
||||
this.CursorMoved = ManageEventOf<CursorMovedEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.CursorMoved), isPerformanceCritical: true);
|
||||
this.MouseWheelScrolled = ManageEventOf<MouseWheelScrolledEventArgs>(nameof(IModEvents.Input), nameof(IInputEvents.MouseWheelScrolled));
|
||||
|
||||
this.PeerContextReceived = ManageEventOf<PeerContextReceivedEventArgs>(nameof(IModEvents.Multiplayer), nameof(IMultiplayerEvents.PeerContextReceived));
|
||||
|
@ -228,8 +235,15 @@ namespace StardewModdingAPI.Framework.Events
|
|||
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
|
||||
|
||||
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
|
||||
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking));
|
||||
this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked));
|
||||
this.UnvalidatedUpdateTicking = ManageEventOf<UnvalidatedUpdateTickingEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicking), isPerformanceCritical: true);
|
||||
this.UnvalidatedUpdateTicked = ManageEventOf<UnvalidatedUpdateTickedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.UnvalidatedUpdateTicked), isPerformanceCritical: true);
|
||||
}
|
||||
|
||||
/// <summary>Get all managed events.</summary>
|
||||
public IEnumerable<IManagedEvent> GetAllEvents()
|
||||
{
|
||||
foreach (FieldInfo field in this.GetType().GetFields())
|
||||
yield return (IManagedEvent)field.GetValue(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
namespace StardewModdingAPI.Framework.Events
|
||||
{
|
||||
/// <summary>Metadata for an event raised by SMAPI.</summary>
|
||||
internal interface IManagedEvent
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>A human-readable name for the event.</summary>
|
||||
string EventName { get; }
|
||||
|
||||
/// <summary>Whether the event is typically called at least once per second.</summary>
|
||||
bool IsPerformanceCritical { get; }
|
||||
}
|
||||
}
|
|
@ -1,12 +1,13 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using StardewModdingAPI.Framework.PerformanceMonitoring;
|
||||
|
||||
namespace StardewModdingAPI.Framework.Events
|
||||
{
|
||||
/// <summary>An event wrapper which intercepts and logs errors in handler code.</summary>
|
||||
/// <typeparam name="TEventArgs">The event arguments type.</typeparam>
|
||||
internal class ManagedEvent<TEventArgs>
|
||||
internal class ManagedEvent<TEventArgs> : IManagedEvent
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
|
@ -14,9 +15,6 @@ namespace StardewModdingAPI.Framework.Events
|
|||
/// <summary>The underlying event.</summary>
|
||||
private event EventHandler<TEventArgs> Event;
|
||||
|
||||
/// <summary>A human-readable name for the event.</summary>
|
||||
private readonly string EventName;
|
||||
|
||||
/// <summary>Writes messages to the log.</summary>
|
||||
private readonly IMonitor Monitor;
|
||||
|
||||
|
@ -29,6 +27,19 @@ namespace StardewModdingAPI.Framework.Events
|
|||
/// <summary>The cached invocation list.</summary>
|
||||
private EventHandler<TEventArgs>[] CachedInvocationList;
|
||||
|
||||
/// <summary>Tracks performance metrics.</summary>
|
||||
private readonly PerformanceMonitor PerformanceMonitor;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>A human-readable name for the event.</summary>
|
||||
public string EventName { get; }
|
||||
|
||||
/// <summary>Whether the event is typically called at least once per second.</summary>
|
||||
public bool IsPerformanceCritical { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
|
@ -37,11 +48,15 @@ namespace StardewModdingAPI.Framework.Events
|
|||
/// <param name="eventName">A human-readable name for the event.</param>
|
||||
/// <param name="monitor">Writes messages to the log.</param>
|
||||
/// <param name="modRegistry">The mod registry with which to identify mods.</param>
|
||||
public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry)
|
||||
/// <param name="performanceMonitor">Tracks performance metrics.</param>
|
||||
/// <param name="isPerformanceCritical">Whether the event is typically called at least once per second.</param>
|
||||
public ManagedEvent(string eventName, IMonitor monitor, ModRegistry modRegistry, PerformanceMonitor performanceMonitor, bool isPerformanceCritical = false)
|
||||
{
|
||||
this.EventName = eventName;
|
||||
this.Monitor = monitor;
|
||||
this.ModRegistry = modRegistry;
|
||||
this.PerformanceMonitor = performanceMonitor;
|
||||
this.IsPerformanceCritical = isPerformanceCritical;
|
||||
}
|
||||
|
||||
/// <summary>Get whether anything is listening to the event.</summary>
|
||||
|
@ -81,17 +96,21 @@ namespace StardewModdingAPI.Framework.Events
|
|||
if (this.Event == null)
|
||||
return;
|
||||
|
||||
foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
|
||||
|
||||
this.PerformanceMonitor.Track(this.EventName, () =>
|
||||
{
|
||||
try
|
||||
foreach (EventHandler<TEventArgs> handler in this.CachedInvocationList)
|
||||
{
|
||||
handler.Invoke(null, args);
|
||||
try
|
||||
{
|
||||
this.PerformanceMonitor.Track(this.EventName, this.GetModNameForPerformanceCounters(handler), () => handler.Invoke(null, args));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.LogError(handler, ex);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.LogError(handler, ex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Raise the event and notify all handlers.</summary>
|
||||
|
@ -122,6 +141,19 @@ namespace StardewModdingAPI.Framework.Events
|
|||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Get the mod name for a given event handler to display in performance monitoring reports.</summary>
|
||||
/// <param name="handler">The event handler.</param>
|
||||
private string GetModNameForPerformanceCounters(EventHandler<TEventArgs> handler)
|
||||
{
|
||||
IModMetadata mod = this.GetSourceMod(handler);
|
||||
if (mod == null)
|
||||
return Constants.GamePerformanceCounterName;
|
||||
|
||||
return mod.HasManifest()
|
||||
? mod.Manifest.UniqueID
|
||||
: mod.DisplayName;
|
||||
}
|
||||
|
||||
/// <summary>Track an event handler.</summary>
|
||||
/// <param name="mod">The mod which added the handler.</param>
|
||||
/// <param name="handler">The event handler.</param>
|
||||
|
|
|
@ -3,8 +3,8 @@ using System.Collections.Generic;
|
|||
|
||||
namespace StardewModdingAPI.Framework
|
||||
{
|
||||
/// <summary>An implementation of <see cref="ISemanticVersion"/> that correctly handles the non-semantic versions used by older Stardew Valley releases.</summary>
|
||||
internal class GameVersion : SemanticVersion
|
||||
/// <summary>An extension of <see cref="ISemanticVersion"/> that correctly handles non-semantic versions used by Stardew Valley.</summary>
|
||||
internal class GameVersion : Toolkit.SemanticVersion
|
||||
{
|
||||
/*********
|
||||
** Private methods
|
||||
|
@ -18,11 +18,11 @@ namespace StardewModdingAPI.Framework
|
|||
["1.03"] = "1.0.3",
|
||||
["1.04"] = "1.0.4",
|
||||
["1.05"] = "1.0.5",
|
||||
["1.051"] = "1.0.6-prerelease1", // not a very good mapping, but good enough for SMAPI's purposes.
|
||||
["1.051b"] = "1.0.6-prerelease2",
|
||||
["1.051"] = "1.0.5.1",
|
||||
["1.051b"] = "1.0.5.2",
|
||||
["1.06"] = "1.0.6",
|
||||
["1.07"] = "1.0.7",
|
||||
["1.07a"] = "1.0.8-prerelease1",
|
||||
["1.07a"] = "1.0.7.1",
|
||||
["1.08"] = "1.0.8",
|
||||
["1.1"] = "1.1.0",
|
||||
["1.2"] = "1.2.0",
|
||||
|
@ -36,7 +36,7 @@ namespace StardewModdingAPI.Framework
|
|||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="version">The game version string.</param>
|
||||
public GameVersion(string version)
|
||||
: base(GameVersion.GetSemanticVersionString(version)) { }
|
||||
: base(GameVersion.GetSemanticVersionString(version), allowNonStandard: true) { }
|
||||
|
||||
/// <summary>Get a string representation of the version.</summary>
|
||||
public override string ToString()
|
||||
|
@ -53,33 +53,21 @@ namespace StardewModdingAPI.Framework
|
|||
private static string GetSemanticVersionString(string gameVersion)
|
||||
{
|
||||
// mapped version
|
||||
if (GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion))
|
||||
return semanticVersion;
|
||||
|
||||
// special case: four-part versions
|
||||
string[] parts = gameVersion.Split('.');
|
||||
if (parts.Length == 4)
|
||||
return $"{parts[0]}.{parts[1]}.{parts[2]}+{parts[3]}";
|
||||
|
||||
return gameVersion;
|
||||
return GameVersion.VersionMap.TryGetValue(gameVersion, out string semanticVersion)
|
||||
? semanticVersion
|
||||
: gameVersion;
|
||||
}
|
||||
|
||||
/// <summary>Convert a semantic version string to the equivalent game version string.</summary>
|
||||
/// <param name="semanticVersion">The semantic version string.</param>
|
||||
private static string GetGameVersionString(string semanticVersion)
|
||||
{
|
||||
// mapped versions
|
||||
foreach (var mapping in GameVersion.VersionMap)
|
||||
{
|
||||
if (mapping.Value.Equals(semanticVersion, StringComparison.InvariantCultureIgnoreCase))
|
||||
return mapping.Key;
|
||||
}
|
||||
|
||||
// special case: four-part versions
|
||||
string[] parts = semanticVersion.Split('.', '+');
|
||||
if (parts.Length == 4)
|
||||
return $"{parts[0]}.{parts[1]}.{parts[2]}.{parts[3]}";
|
||||
|
||||
return semanticVersion;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using Microsoft.Xna.Framework.Graphics;
|
||||
using StardewModdingAPI.Framework.Events;
|
||||
using StardewModdingAPI.Framework.Reflection;
|
||||
|
@ -83,6 +84,75 @@ namespace StardewModdingAPI.Framework
|
|||
return exception;
|
||||
}
|
||||
|
||||
/****
|
||||
** ReaderWriterLockSlim
|
||||
****/
|
||||
/// <summary>Run code within a read lock.</summary>
|
||||
/// <param name="lock">The lock to set.</param>
|
||||
/// <param name="action">The action to perform.</param>
|
||||
public static void InReadLock(this ReaderWriterLockSlim @lock, Action action)
|
||||
{
|
||||
@lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Run code within a read lock.</summary>
|
||||
/// <typeparam name="TReturn">The action's return value.</typeparam>
|
||||
/// <param name="lock">The lock to set.</param>
|
||||
/// <param name="action">The action to perform.</param>
|
||||
public static TReturn InReadLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
|
||||
{
|
||||
@lock.EnterReadLock();
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@lock.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Run code within a write lock.</summary>
|
||||
/// <param name="lock">The lock to set.</param>
|
||||
/// <param name="action">The action to perform.</param>
|
||||
public static void InWriteLock(this ReaderWriterLockSlim @lock, Action action)
|
||||
{
|
||||
@lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Run code within a write lock.</summary>
|
||||
/// <typeparam name="TReturn">The action's return value.</typeparam>
|
||||
/// <param name="lock">The lock to set.</param>
|
||||
/// <param name="action">The action to perform.</param>
|
||||
public static TReturn InWriteLock<TReturn>(this ReaderWriterLockSlim @lock, Func<TReturn> action)
|
||||
{
|
||||
@lock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
return action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@lock.ExitWriteLock();
|
||||
}
|
||||
}
|
||||
|
||||
/****
|
||||
** Sprite batch
|
||||
****/
|
||||
|
|
|
@ -177,7 +177,7 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
|||
private string GetGlobalDataPath(string key)
|
||||
{
|
||||
this.AssertSlug(key, nameof(key));
|
||||
return Path.Combine(Constants.SavesPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
|
||||
return Path.Combine(Constants.DataPath, ".smapi", "mod-data", this.ModID.ToLower(), $"{key}.json".ToLower());
|
||||
}
|
||||
|
||||
/// <summary>Assert that a key contains only characters that are safe in all contexts.</summary>
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
namespace StardewModdingAPI.Framework
|
||||
{
|
||||
/// <summary>A generic tuple which links something to a mod.</summary>
|
||||
/// <typeparam name="T">The interceptor type.</typeparam>
|
||||
internal class ModLinked<T>
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The mod metadata.</summary>
|
||||
public IModMetadata Mod { get; }
|
||||
|
||||
/// <summary>The instance linked to the mod.</summary>
|
||||
public T Data { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="mod">The mod metadata.</param>
|
||||
/// <param name="data">The instance linked to the mod.</param>
|
||||
public ModLinked(IModMetadata mod, T data)
|
||||
{
|
||||
this.Mod = mod;
|
||||
this.Data = data;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>The context for an alert.</summary>
|
||||
internal struct AlertContext
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The source which triggered the alert.</summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>The elapsed milliseconds.</summary>
|
||||
public double Elapsed { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="source">The source which triggered the alert.</param>
|
||||
/// <param name="elapsed">The elapsed milliseconds.</param>
|
||||
public AlertContext(string source, double elapsed)
|
||||
{
|
||||
this.Source = source;
|
||||
this.Elapsed = elapsed;
|
||||
}
|
||||
|
||||
/// <summary>Get a human-readable text form of this instance.</summary>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{this.Source}: {this.Elapsed:F2}ms";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>A single alert entry.</summary>
|
||||
internal struct AlertEntry
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The collection in which the alert occurred.</summary>
|
||||
public PerformanceCounterCollection Collection { get; }
|
||||
|
||||
/// <summary>The actual execution time in milliseconds.</summary>
|
||||
public double ExecutionTimeMilliseconds { get; }
|
||||
|
||||
/// <summary>The configured alert threshold in milliseconds.</summary>
|
||||
public double ThresholdMilliseconds { get; }
|
||||
|
||||
/// <summary>The sources involved in exceeding the threshold.</summary>
|
||||
public AlertContext[] Context { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="collection">The collection in which the alert occurred.</param>
|
||||
/// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
|
||||
/// <param name="thresholdMilliseconds">The configured alert threshold in milliseconds.</param>
|
||||
/// <param name="context">The sources involved in exceeding the threshold.</param>
|
||||
public AlertEntry(PerformanceCounterCollection collection, double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] context)
|
||||
{
|
||||
this.Collection = collection;
|
||||
this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
|
||||
this.ThresholdMilliseconds = thresholdMilliseconds;
|
||||
this.Context = context;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
using System;
|
||||
|
||||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>A peak invocation time.</summary>
|
||||
internal struct PeakEntry
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The actual execution time in milliseconds.</summary>
|
||||
public double ExecutionTimeMilliseconds { get; }
|
||||
|
||||
/// <summary>When the entry occurred.</summary>
|
||||
public DateTime EventTime { get; }
|
||||
|
||||
/// <summary>The sources involved in exceeding the threshold.</summary>
|
||||
public AlertContext[] Context { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="executionTimeMilliseconds">The actual execution time in milliseconds.</param>
|
||||
/// <param name="eventTime">When the entry occurred.</param>
|
||||
/// <param name="context">The sources involved in exceeding the threshold.</param>
|
||||
public PeakEntry(double executionTimeMilliseconds, DateTime eventTime, AlertContext[] context)
|
||||
{
|
||||
this.ExecutionTimeMilliseconds = executionTimeMilliseconds;
|
||||
this.EventTime = eventTime;
|
||||
this.Context = context;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,125 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Harmony;
|
||||
|
||||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>Tracks metadata about a particular code event.</summary>
|
||||
internal class PerformanceCounter
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The size of the ring buffer.</summary>
|
||||
private readonly int MaxEntries = 16384;
|
||||
|
||||
/// <summary>The collection to which this performance counter belongs.</summary>
|
||||
private readonly PerformanceCounterCollection ParentCollection;
|
||||
|
||||
/// <summary>The performance counter entries.</summary>
|
||||
private readonly Stack<PerformanceCounterEntry> Entries;
|
||||
|
||||
/// <summary>The entry with the highest execution time.</summary>
|
||||
private PerformanceCounterEntry? PeakPerformanceCounterEntry;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The name of the source.</summary>
|
||||
public string Source { get; }
|
||||
|
||||
/// <summary>The alert threshold in milliseconds</summary>
|
||||
public double AlertThresholdMilliseconds { get; set; }
|
||||
|
||||
/// <summary>If alerting is enabled or not</summary>
|
||||
public bool EnableAlerts { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="parentCollection">The collection to which this performance counter belongs.</param>
|
||||
/// <param name="source">The name of the source.</param>
|
||||
public PerformanceCounter(PerformanceCounterCollection parentCollection, string source)
|
||||
{
|
||||
this.ParentCollection = parentCollection;
|
||||
this.Source = source;
|
||||
this.Entries = new Stack<PerformanceCounterEntry>(this.MaxEntries);
|
||||
}
|
||||
|
||||
/// <summary>Add a performance counter entry to the list, update monitoring, and raise alerts if needed.</summary>
|
||||
/// <param name="entry">The entry to add.</param>
|
||||
public void Add(PerformanceCounterEntry entry)
|
||||
{
|
||||
// add entry
|
||||
if (this.Entries.Count > this.MaxEntries)
|
||||
this.Entries.Pop();
|
||||
this.Entries.Add(entry);
|
||||
|
||||
// update metrics
|
||||
if (this.PeakPerformanceCounterEntry == null || entry.ElapsedMilliseconds > this.PeakPerformanceCounterEntry.Value.ElapsedMilliseconds)
|
||||
this.PeakPerformanceCounterEntry = entry;
|
||||
|
||||
// raise alert
|
||||
if (this.EnableAlerts && entry.ElapsedMilliseconds > this.AlertThresholdMilliseconds)
|
||||
this.ParentCollection.AddAlert(entry.ElapsedMilliseconds, this.AlertThresholdMilliseconds, new AlertContext(this.Source, entry.ElapsedMilliseconds));
|
||||
}
|
||||
|
||||
/// <summary>Clear all performance counter entries and monitoring.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
this.Entries.Clear();
|
||||
this.PeakPerformanceCounterEntry = null;
|
||||
}
|
||||
|
||||
/// <summary>Get the peak entry.</summary>
|
||||
public PerformanceCounterEntry? GetPeak()
|
||||
{
|
||||
return this.PeakPerformanceCounterEntry;
|
||||
}
|
||||
|
||||
/// <summary>Get the entry with the highest execution time.</summary>
|
||||
/// <param name="range">The time range to search.</param>
|
||||
/// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
|
||||
public PerformanceCounterEntry? GetPeak(TimeSpan range, DateTime? endTime = null)
|
||||
{
|
||||
endTime ??= DateTime.UtcNow;
|
||||
DateTime startTime = endTime.Value.Subtract(range);
|
||||
|
||||
return this.Entries
|
||||
.Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
|
||||
.OrderByDescending(x => x.ElapsedMilliseconds)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Get the last entry added to the list.</summary>
|
||||
public PerformanceCounterEntry? GetLastEntry()
|
||||
{
|
||||
if (this.Entries.Count == 0)
|
||||
return null;
|
||||
|
||||
return this.Entries.Peek();
|
||||
}
|
||||
|
||||
/// <summary>Get the average over a given time span.</summary>
|
||||
/// <param name="range">The time range to search.</param>
|
||||
/// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
|
||||
public double GetAverage(TimeSpan range, DateTime? endTime = null)
|
||||
{
|
||||
endTime ??= DateTime.UtcNow;
|
||||
DateTime startTime = endTime.Value.Subtract(range);
|
||||
|
||||
double[] entries = this.Entries
|
||||
.Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
|
||||
.Select(p => p.ElapsedMilliseconds)
|
||||
.ToArray();
|
||||
|
||||
return entries.Length > 0
|
||||
? entries.Average()
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,205 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
internal class PerformanceCounterCollection
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The number of peak invocations to keep.</summary>
|
||||
private readonly int MaxEntries = 16384;
|
||||
|
||||
/// <summary>The sources involved in exceeding alert thresholds.</summary>
|
||||
private readonly List<AlertContext> TriggeredPerformanceCounters = new List<AlertContext>();
|
||||
|
||||
/// <summary>The stopwatch used to track the invocation time.</summary>
|
||||
private readonly Stopwatch InvocationStopwatch = new Stopwatch();
|
||||
|
||||
/// <summary>The performance counter manager.</summary>
|
||||
private readonly PerformanceMonitor PerformanceMonitor;
|
||||
|
||||
/// <summary>The time to calculate average calls per second.</summary>
|
||||
private DateTime CallsPerSecondStart = DateTime.UtcNow;
|
||||
|
||||
/// <summary>The number of invocations.</summary>
|
||||
private long CallCount;
|
||||
|
||||
/// <summary>The peak invocations.</summary>
|
||||
private readonly Stack<PeakEntry> PeakInvocations;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>The associated performance counters.</summary>
|
||||
public IDictionary<string, PerformanceCounter> PerformanceCounters { get; } = new Dictionary<string, PerformanceCounter>();
|
||||
|
||||
/// <summary>The name of this collection.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>Whether the source is typically invoked at least once per second.</summary>
|
||||
public bool IsPerformanceCritical { get; }
|
||||
|
||||
/// <summary>The alert threshold in milliseconds.</summary>
|
||||
public double AlertThresholdMilliseconds { get; set; }
|
||||
|
||||
/// <summary>Whether alerts are enabled.</summary>
|
||||
public bool EnableAlerts { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="performanceMonitor">The performance counter manager.</param>
|
||||
/// <param name="name">The name of this collection.</param>
|
||||
/// <param name="isPerformanceCritical">Whether the source is typically invoked at least once per second.</param>
|
||||
public PerformanceCounterCollection(PerformanceMonitor performanceMonitor, string name, bool isPerformanceCritical = false)
|
||||
{
|
||||
this.PeakInvocations = new Stack<PeakEntry>(this.MaxEntries);
|
||||
this.Name = name;
|
||||
this.PerformanceMonitor = performanceMonitor;
|
||||
this.IsPerformanceCritical = isPerformanceCritical;
|
||||
}
|
||||
|
||||
/// <summary>Track a single invocation for a named source.</summary>
|
||||
/// <param name="source">The name of the source.</param>
|
||||
/// <param name="entry">The entry.</param>
|
||||
public void Track(string source, PerformanceCounterEntry entry)
|
||||
{
|
||||
// add entry
|
||||
if (!this.PerformanceCounters.ContainsKey(source))
|
||||
this.PerformanceCounters.Add(source, new PerformanceCounter(this, source));
|
||||
this.PerformanceCounters[source].Add(entry);
|
||||
|
||||
// raise alert
|
||||
if (this.EnableAlerts)
|
||||
this.TriggeredPerformanceCounters.Add(new AlertContext(source, entry.ElapsedMilliseconds));
|
||||
}
|
||||
|
||||
/// <summary>Get the average execution time for all non-game internal sources in milliseconds.</summary>
|
||||
/// <param name="interval">The interval for which to get the average, relative to now</param>
|
||||
public double GetModsAverageExecutionTime(TimeSpan interval)
|
||||
{
|
||||
return this.PerformanceCounters
|
||||
.Where(entry => entry.Key != Constants.GamePerformanceCounterName)
|
||||
.Sum(entry => entry.Value.GetAverage(interval));
|
||||
}
|
||||
|
||||
/// <summary>Get the overall average execution time in milliseconds.</summary>
|
||||
/// <param name="interval">The interval for which to get the average, relative to now</param>
|
||||
public double GetAverageExecutionTime(TimeSpan interval)
|
||||
{
|
||||
return this.PerformanceCounters
|
||||
.Sum(entry => entry.Value.GetAverage(interval));
|
||||
}
|
||||
|
||||
/// <summary>Get the average execution time for game-internal sources in milliseconds.</summary>
|
||||
public double GetGameAverageExecutionTime(TimeSpan interval)
|
||||
{
|
||||
return this.PerformanceCounters.TryGetValue(Constants.GamePerformanceCounterName, out PerformanceCounter gameExecTime)
|
||||
? gameExecTime.GetAverage(interval)
|
||||
: 0;
|
||||
}
|
||||
|
||||
/// <summary>Get the peak execution time in milliseconds.</summary>
|
||||
/// <param name="range">The time range to search.</param>
|
||||
/// <param name="endTime">The end time for the <paramref name="range"/>, or null for the current time.</param>
|
||||
public double GetPeakExecutionTime(TimeSpan range, DateTime? endTime = null)
|
||||
{
|
||||
if (this.PeakInvocations.Count == 0)
|
||||
return 0;
|
||||
|
||||
endTime ??= DateTime.UtcNow;
|
||||
DateTime startTime = endTime.Value.Subtract(range);
|
||||
|
||||
return this.PeakInvocations
|
||||
.Where(entry => entry.EventTime >= startTime && entry.EventTime <= endTime)
|
||||
.OrderByDescending(x => x.ExecutionTimeMilliseconds)
|
||||
.Select(p => p.ExecutionTimeMilliseconds)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Start tracking the invocation of this collection.</summary>
|
||||
public void BeginTrackInvocation()
|
||||
{
|
||||
this.TriggeredPerformanceCounters.Clear();
|
||||
this.InvocationStopwatch.Reset();
|
||||
this.InvocationStopwatch.Start();
|
||||
|
||||
this.CallCount++;
|
||||
}
|
||||
|
||||
/// <summary>End tracking the invocation of this collection, and raise an alert if needed.</summary>
|
||||
public void EndTrackInvocation()
|
||||
{
|
||||
this.InvocationStopwatch.Stop();
|
||||
|
||||
// add invocation
|
||||
if (this.PeakInvocations.Count >= this.MaxEntries)
|
||||
this.PeakInvocations.Pop();
|
||||
this.PeakInvocations.Push(new PeakEntry(this.InvocationStopwatch.Elapsed.TotalMilliseconds, DateTime.UtcNow, this.TriggeredPerformanceCounters.ToArray()));
|
||||
|
||||
// raise alert
|
||||
if (this.EnableAlerts && this.InvocationStopwatch.Elapsed.TotalMilliseconds >= this.AlertThresholdMilliseconds)
|
||||
this.AddAlert(this.InvocationStopwatch.Elapsed.TotalMilliseconds, this.AlertThresholdMilliseconds, this.TriggeredPerformanceCounters.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>Add an alert.</summary>
|
||||
/// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
|
||||
/// <param name="thresholdMilliseconds">The configured threshold.</param>
|
||||
/// <param name="alerts">The sources involved in exceeding the threshold.</param>
|
||||
public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext[] alerts)
|
||||
{
|
||||
this.PerformanceMonitor.AddAlert(
|
||||
new AlertEntry(this, executionTimeMilliseconds, thresholdMilliseconds, alerts)
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Add an alert.</summary>
|
||||
/// <param name="executionTimeMilliseconds">The execution time in milliseconds.</param>
|
||||
/// <param name="thresholdMilliseconds">The configured threshold.</param>
|
||||
/// <param name="alert">The source involved in exceeding the threshold.</param>
|
||||
public void AddAlert(double executionTimeMilliseconds, double thresholdMilliseconds, AlertContext alert)
|
||||
{
|
||||
this.AddAlert(executionTimeMilliseconds, thresholdMilliseconds, new[] { alert });
|
||||
}
|
||||
|
||||
/// <summary>Reset the calls per second counter.</summary>
|
||||
public void ResetCallsPerSecond()
|
||||
{
|
||||
this.CallCount = 0;
|
||||
this.CallsPerSecondStart = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>Reset all performance counters in this collection.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
this.PeakInvocations.Clear();
|
||||
foreach (var counter in this.PerformanceCounters)
|
||||
counter.Value.Reset();
|
||||
}
|
||||
|
||||
/// <summary>Reset the performance counter for a specific source.</summary>
|
||||
/// <param name="source">The source name.</param>
|
||||
public void ResetSource(string source)
|
||||
{
|
||||
foreach (var i in this.PerformanceCounters)
|
||||
if (i.Value.Source.Equals(source, StringComparison.InvariantCultureIgnoreCase))
|
||||
i.Value.Reset();
|
||||
}
|
||||
|
||||
/// <summary>Get the average calls per second.</summary>
|
||||
public long GetAverageCallsPerSecond()
|
||||
{
|
||||
long runtimeInSeconds = (long)DateTime.UtcNow.Subtract(this.CallsPerSecondStart).TotalSeconds;
|
||||
return runtimeInSeconds > 0
|
||||
? this.CallCount / runtimeInSeconds
|
||||
: 0;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
using System;
|
||||
|
||||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>A single performance counter entry.</summary>
|
||||
internal struct PerformanceCounterEntry
|
||||
{
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>When the entry occurred.</summary>
|
||||
public DateTime EventTime { get; }
|
||||
|
||||
/// <summary>The elapsed milliseconds.</summary>
|
||||
public double ElapsedMilliseconds { get; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="eventTime">When the entry occurred.</param>
|
||||
/// <param name="elapsedMilliseconds">The elapsed milliseconds.</param>
|
||||
public PerformanceCounterEntry(DateTime eventTime, double elapsedMilliseconds)
|
||||
{
|
||||
this.EventTime = eventTime;
|
||||
this.ElapsedMilliseconds = elapsedMilliseconds;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,184 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using StardewModdingAPI.Framework.Events;
|
||||
|
||||
namespace StardewModdingAPI.Framework.PerformanceMonitoring
|
||||
{
|
||||
/// <summary>Tracks performance metrics.</summary>
|
||||
internal class PerformanceMonitor
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>The recorded alerts.</summary>
|
||||
private readonly IList<AlertEntry> Alerts = new List<AlertEntry>();
|
||||
|
||||
/// <summary>The monitor for output logging.</summary>
|
||||
private readonly IMonitor Monitor;
|
||||
|
||||
/// <summary>The invocation stopwatch.</summary>
|
||||
private readonly Stopwatch InvocationStopwatch = new Stopwatch();
|
||||
|
||||
/// <summary>The underlying performance counter collections.</summary>
|
||||
private readonly IDictionary<string, PerformanceCounterCollection> Collections = new Dictionary<string, PerformanceCounterCollection>(StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>Whether alerts are paused.</summary>
|
||||
public bool PauseAlerts { get; set; }
|
||||
|
||||
/// <summary>Whether performance counter tracking is enabled.</summary>
|
||||
public bool EnableTracking { get; set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="monitor">The monitor for output logging.</param>
|
||||
public PerformanceMonitor(IMonitor monitor)
|
||||
{
|
||||
this.Monitor = monitor;
|
||||
}
|
||||
|
||||
/// <summary>Reset all performance counters in all collections.</summary>
|
||||
public void Reset()
|
||||
{
|
||||
foreach (PerformanceCounterCollection collection in this.Collections.Values)
|
||||
collection.Reset();
|
||||
}
|
||||
|
||||
/// <summary>Track the invocation time for a collection.</summary>
|
||||
/// <param name="collectionName">The name of the collection.</param>
|
||||
/// <param name="action">The action to execute and track.</param>
|
||||
public void Track(string collectionName, Action action)
|
||||
{
|
||||
if (!this.EnableTracking)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
|
||||
collection.BeginTrackInvocation();
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
collection.EndTrackInvocation();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Track a single performance counter invocation in a specific collection.</summary>
|
||||
/// <param name="collectionName">The name of the collection.</param>
|
||||
/// <param name="sourceName">The name of the source.</param>
|
||||
/// <param name="action">The action to execute and track.</param>
|
||||
public void Track(string collectionName, string sourceName, Action action)
|
||||
{
|
||||
if (!this.EnableTracking)
|
||||
{
|
||||
action();
|
||||
return;
|
||||
}
|
||||
|
||||
PerformanceCounterCollection collection = this.GetOrCreateCollectionByName(collectionName);
|
||||
DateTime eventTime = DateTime.UtcNow;
|
||||
this.InvocationStopwatch.Reset();
|
||||
this.InvocationStopwatch.Start();
|
||||
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
finally
|
||||
{
|
||||
this.InvocationStopwatch.Stop();
|
||||
collection.Track(sourceName, new PerformanceCounterEntry(eventTime, this.InvocationStopwatch.Elapsed.TotalMilliseconds));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset the performance counters for a specific collection.</summary>
|
||||
/// <param name="name">The collection name.</param>
|
||||
public void ResetCollection(string name)
|
||||
{
|
||||
if (this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
|
||||
{
|
||||
collection.ResetCallsPerSecond();
|
||||
collection.Reset();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reset performance counters for a specific source.</summary>
|
||||
/// <param name="name">The name of the source.</param>
|
||||
public void ResetSource(string name)
|
||||
{
|
||||
foreach (PerformanceCounterCollection performanceCounterCollection in this.Collections.Values)
|
||||
performanceCounterCollection.ResetSource(name);
|
||||
}
|
||||
|
||||
/// <summary>Print any queued alerts.</summary>
|
||||
public void PrintQueuedAlerts()
|
||||
{
|
||||
if (this.Alerts.Count == 0)
|
||||
return;
|
||||
|
||||
StringBuilder report = new StringBuilder();
|
||||
|
||||
foreach (AlertEntry alert in this.Alerts)
|
||||
{
|
||||
report.AppendLine($"{alert.Collection.Name} took {alert.ExecutionTimeMilliseconds:F2}ms (exceeded threshold of {alert.ThresholdMilliseconds:F2}ms)");
|
||||
|
||||
foreach (AlertContext context in alert.Context.OrderByDescending(p => p.Elapsed))
|
||||
report.AppendLine(context.ToString());
|
||||
}
|
||||
|
||||
this.Alerts.Clear();
|
||||
this.Monitor.Log(report.ToString(), LogLevel.Error);
|
||||
}
|
||||
|
||||
/// <summary>Add an alert to the queue.</summary>
|
||||
/// <param name="entry">The alert to add.</param>
|
||||
public void AddAlert(AlertEntry entry)
|
||||
{
|
||||
if (!this.PauseAlerts)
|
||||
this.Alerts.Add(entry);
|
||||
}
|
||||
|
||||
/// <summary>Initialize the default performance counter collections.</summary>
|
||||
/// <param name="eventManager">The event manager.</param>
|
||||
public void InitializePerformanceCounterCollections(EventManager eventManager)
|
||||
{
|
||||
foreach (IManagedEvent @event in eventManager.GetAllEvents())
|
||||
this.Collections[@event.EventName] = new PerformanceCounterCollection(this, @event.EventName, @event.IsPerformanceCritical);
|
||||
}
|
||||
|
||||
/// <summary>Get the underlying performance counters.</summary>
|
||||
public IEnumerable<PerformanceCounterCollection> GetCollections()
|
||||
{
|
||||
return this.Collections.Values;
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Get a collection by name and creates it if it doesn't exist.</summary>
|
||||
/// <param name="name">The name of the collection.</param>
|
||||
private PerformanceCounterCollection GetOrCreateCollectionByName(string name)
|
||||
{
|
||||
if (!this.Collections.TryGetValue(name, out PerformanceCounterCollection collection))
|
||||
{
|
||||
collection = new PerformanceCounterCollection(this, name);
|
||||
this.Collections[name] = collection;
|
||||
}
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,6 +25,7 @@ using StardewModdingAPI.Framework.Models;
|
|||
using StardewModdingAPI.Framework.ModHelpers;
|
||||
using StardewModdingAPI.Framework.ModLoading;
|
||||
using StardewModdingAPI.Framework.Patching;
|
||||
using StardewModdingAPI.Framework.PerformanceMonitoring;
|
||||
using StardewModdingAPI.Framework.Reflection;
|
||||
using StardewModdingAPI.Framework.Serialization;
|
||||
using StardewModdingAPI.Patches;
|
||||
|
@ -111,7 +112,7 @@ namespace StardewModdingAPI.Framework
|
|||
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
|
||||
#endif
|
||||
logLevel: LogLevel.Error
|
||||
),
|
||||
),
|
||||
|
||||
// save file not found error
|
||||
new ReplaceLogPattern(
|
||||
|
@ -135,6 +136,10 @@ namespace StardewModdingAPI.Framework
|
|||
/// <remarks>This is initialized after the game starts. This is accessed directly because it's not part of the normal class model.</remarks>
|
||||
internal static DeprecationManager DeprecationManager { get; private set; }
|
||||
|
||||
/// <summary>Manages performance counters.</summary>
|
||||
/// <remarks>This is initialized after the game starts. This is non-private for use by Console Commands.</remarks>
|
||||
internal static PerformanceMonitor PerformanceMonitor { get; private set; }
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
|
@ -155,6 +160,9 @@ namespace StardewModdingAPI.Framework
|
|||
|
||||
// init basics
|
||||
this.Settings = JsonConvert.DeserializeObject<SConfig>(File.ReadAllText(Constants.ApiConfigPath));
|
||||
if (File.Exists(Constants.ApiUserConfigPath))
|
||||
JsonConvert.PopulateObject(File.ReadAllText(Constants.ApiUserConfigPath), this.Settings);
|
||||
|
||||
this.LogFile = new LogFileManager(logPath);
|
||||
this.Monitor = new Monitor("SMAPI", this.ConsoleManager, this.LogFile, this.Settings.ConsoleColors, this.Settings.VerboseLogging)
|
||||
{
|
||||
|
@ -163,7 +171,11 @@ namespace StardewModdingAPI.Framework
|
|||
ShowFullStampInConsole = this.Settings.DeveloperMode
|
||||
};
|
||||
this.MonitorForGame = this.GetSecondaryMonitor("game");
|
||||
this.EventManager = new EventManager(this.Monitor, this.ModRegistry);
|
||||
|
||||
SCore.PerformanceMonitor = new PerformanceMonitor(this.Monitor);
|
||||
this.EventManager = new EventManager(this.Monitor, this.ModRegistry, SCore.PerformanceMonitor);
|
||||
SCore.PerformanceMonitor.InitializePerformanceCounterCollections(this.EventManager);
|
||||
|
||||
SCore.DeprecationManager = new DeprecationManager(this.Monitor, this.ModRegistry);
|
||||
|
||||
// redirect direct console output
|
||||
|
@ -196,7 +208,7 @@ namespace StardewModdingAPI.Framework
|
|||
#else
|
||||
if (Constants.Platform == Platform.Windows)
|
||||
{
|
||||
this.Monitor.Log("Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error);
|
||||
this.Monitor.Log($"Oops! You're running {Constants.Platform}, but this version of SMAPI is for Windows. Please reinstall SMAPI to fix this.", LogLevel.Error);
|
||||
this.PressAnyKeyToExit();
|
||||
return;
|
||||
}
|
||||
|
@ -226,7 +238,7 @@ namespace StardewModdingAPI.Framework
|
|||
#endif
|
||||
AppDomain.CurrentDomain.UnhandledException += (sender, e) => this.Monitor.Log($"Critical app domain exception: {e.ExceptionObject}", LogLevel.Error);
|
||||
|
||||
// add more lenient assembly resolvers
|
||||
// add more lenient assembly resolver
|
||||
AppDomain.CurrentDomain.AssemblyResolve += (sender, e) => AssemblyLoader.ResolveAssembly(e.Name);
|
||||
|
||||
// hook locale event
|
||||
|
@ -243,6 +255,7 @@ namespace StardewModdingAPI.Framework
|
|||
jsonHelper: this.Toolkit.JsonHelper,
|
||||
modRegistry: this.ModRegistry,
|
||||
deprecationManager: SCore.DeprecationManager,
|
||||
performanceMonitor: SCore.PerformanceMonitor,
|
||||
onGameInitialized: this.InitializeAfterGameStart,
|
||||
onGameExiting: this.Dispose,
|
||||
cancellationToken: this.CancellationToken,
|
||||
|
@ -258,6 +271,7 @@ namespace StardewModdingAPI.Framework
|
|||
new ObjectErrorPatch(),
|
||||
new LoadContextPatch(this.Reflection, this.GameInstance.OnLoadStageChanged),
|
||||
new LoadErrorPatch(this.Monitor, this.GameInstance.OnSaveContentRemoved),
|
||||
new ScheduleErrorPatch(this.MonitorForGame)
|
||||
new SaveBackupPatch(this.EventManager)
|
||||
);
|
||||
|
||||
|
@ -313,7 +327,7 @@ namespace StardewModdingAPI.Framework
|
|||
// show details if game crashed during last session
|
||||
if (File.Exists(Constants.FatalCrashMarker))
|
||||
{
|
||||
this.Monitor.Log("The game crashed last time you played. That can be due to bugs in the game, but if it happens repeatedly you can ask for help here: https://community.playstarbound.com/threads/108375/.", LogLevel.Error);
|
||||
this.Monitor.Log("The game crashed last time you played. If it happens repeatedly, see 'get help' on https://smapi.io.", LogLevel.Error);
|
||||
this.Monitor.Log("If you ask for help, make sure to share your SMAPI log: https://smapi.io/log.", LogLevel.Error);
|
||||
this.Monitor.Log("Press any key to delete the crash data and continue playing.", LogLevel.Info);
|
||||
//Console.ReadKey();
|
||||
|
@ -412,6 +426,17 @@ namespace StardewModdingAPI.Framework
|
|||
return;
|
||||
}
|
||||
|
||||
// init TMX support
|
||||
try
|
||||
{
|
||||
xTile.Format.FormatManager.Instance.RegisterMapFormat(new TMXTile.TMXFormat(Game1.tileSize / Game1.pixelZoom, Game1.tileSize / Game1.pixelZoom, Game1.pixelZoom, Game1.pixelZoom));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Monitor.Log("SMAPI couldn't load TMX support. Some mods may not work correctly.", LogLevel.Warn);
|
||||
this.Monitor.Log($"Technical details: {ex.GetLogSummary()}", LogLevel.Trace);
|
||||
}
|
||||
|
||||
this.GameInstance.IsGameSuspended = true;
|
||||
new Thread(() =>
|
||||
{
|
||||
|
@ -610,6 +635,8 @@ namespace StardewModdingAPI.Framework
|
|||
else
|
||||
this.Monitor.Log(" SMAPI okay.", LogLevel.Trace);
|
||||
|
||||
updateFound = response.SuggestedUpdate?.Version;
|
||||
|
||||
// show errors
|
||||
if (response.Errors.Any())
|
||||
{
|
||||
|
@ -812,13 +839,13 @@ namespace StardewModdingAPI.Framework
|
|||
{
|
||||
// ReSharper disable SuspiciousTypeConversion.Global
|
||||
if (metadata.Mod is IAssetEditor editor)
|
||||
helper.ObservableAssetEditors.Add(editor);
|
||||
this.ContentCore.Editors.Add(new ModLinked<IAssetEditor>(metadata, editor));
|
||||
if (metadata.Mod is IAssetLoader loader)
|
||||
helper.ObservableAssetLoaders.Add(loader);
|
||||
this.ContentCore.Loaders.Add(new ModLinked<IAssetLoader>(metadata, loader));
|
||||
// ReSharper restore SuspiciousTypeConversion.Global
|
||||
|
||||
this.ContentCore.Editors[metadata] = helper.ObservableAssetEditors;
|
||||
this.ContentCore.Loaders[metadata] = helper.ObservableAssetLoaders;
|
||||
helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetEditor>(), e.OldItems?.Cast<IAssetEditor>(), this.ContentCore.Editors);
|
||||
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.OnInterceptorsChanged(metadata, e.NewItems?.Cast<IAssetLoader>(), e.OldItems?.Cast<IAssetLoader>(), this.ContentCore.Loaders);
|
||||
}
|
||||
|
||||
// call entry method
|
||||
|
@ -867,6 +894,24 @@ namespace StardewModdingAPI.Framework
|
|||
this.ModRegistry.AreAllModsInitialized = true;
|
||||
}
|
||||
|
||||
/// <summary>Handle a mod adding or removing asset interceptors.</summary>
|
||||
/// <typeparam name="T">The asset interceptor type (one of <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/>).</typeparam>
|
||||
/// <param name="mod">The mod metadata.</param>
|
||||
/// <param name="added">The interceptors that were added.</param>
|
||||
/// <param name="removed">The interceptors that were removed.</param>
|
||||
/// <param name="list">The list to update.</param>
|
||||
private void OnInterceptorsChanged<T>(IModMetadata mod, IEnumerable<T> added, IEnumerable<T> removed, IList<ModLinked<T>> list)
|
||||
{
|
||||
foreach (T interceptor in added ?? new T[0])
|
||||
list.Add(new ModLinked<T>(mod, interceptor));
|
||||
|
||||
foreach (T interceptor in removed ?? new T[0])
|
||||
{
|
||||
foreach (ModLinked<T> entry in list.Where(p => p.Mod == mod && object.ReferenceEquals(p.Data, interceptor)).ToArray())
|
||||
list.Remove(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Load a given mod.</summary>
|
||||
/// <param name="mod">The mod to load.</param>
|
||||
/// <param name="mods">The mods being loaded.</param>
|
||||
|
|
|
@ -17,6 +17,7 @@ using StardewModdingAPI.Framework.Content;
|
|||
using StardewModdingAPI.Framework.Events;
|
||||
using StardewModdingAPI.Framework.Input;
|
||||
using StardewModdingAPI.Framework.Networking;
|
||||
using StardewModdingAPI.Framework.PerformanceMonitoring;
|
||||
using StardewModdingAPI.Framework.Reflection;
|
||||
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||
using StardewModdingAPI.Framework.StateTracking.Snapshots;
|
||||
|
@ -62,6 +63,9 @@ namespace StardewModdingAPI.Framework
|
|||
/// <summary>Manages deprecation warnings.</summary>
|
||||
private readonly DeprecationManager DeprecationManager;
|
||||
|
||||
/// <summary>Tracks performance metrics.</summary>
|
||||
private readonly PerformanceMonitor PerformanceMonitor;
|
||||
|
||||
/// <summary>The maximum number of consecutive attempts SMAPI should make to recover from a draw error.</summary>
|
||||
private readonly Countdown DrawCrashTimer = new Countdown(60); // 60 ticks = roughly one second
|
||||
|
||||
|
@ -161,11 +165,12 @@ namespace StardewModdingAPI.Framework
|
|||
/// <param name="jsonHelper">Encapsulates SMAPI's JSON file parsing.</param>
|
||||
/// <param name="modRegistry">Tracks the installed mods.</param>
|
||||
/// <param name="deprecationManager">Manages deprecation warnings.</param>
|
||||
/// <param name="performanceMonitor">Tracks performance metrics.</param>
|
||||
/// <param name="onGameInitialized">A callback to invoke after the game finishes initializing.</param>
|
||||
/// <param name="onGameExiting">A callback to invoke when the game exits.</param>
|
||||
/// <param name="cancellationToken">Propagates notification that SMAPI should exit.</param>
|
||||
/// <param name="logNetworkTraffic">Whether to log network traffic.</param>
|
||||
internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
|
||||
internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, PerformanceMonitor performanceMonitor, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic)
|
||||
{
|
||||
this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset;
|
||||
SGame.ConstructorHack = null;
|
||||
|
@ -185,6 +190,7 @@ namespace StardewModdingAPI.Framework
|
|||
this.Reflection = reflection;
|
||||
this.Translator = translator;
|
||||
this.DeprecationManager = deprecationManager;
|
||||
this.PerformanceMonitor = performanceMonitor;
|
||||
this.OnGameInitialized = onGameInitialized;
|
||||
this.OnGameExiting = onGameExiting;
|
||||
Game1.input = new SInputState();
|
||||
|
@ -326,6 +332,7 @@ namespace StardewModdingAPI.Framework
|
|||
try
|
||||
{
|
||||
this.DeprecationManager.PrintQueued();
|
||||
this.PerformanceMonitor.PrintQueuedAlerts();
|
||||
|
||||
/*********
|
||||
** First-tick initialization
|
||||
|
@ -414,7 +421,7 @@ namespace StardewModdingAPI.Framework
|
|||
// state while mods are running their code. This is risky, because data changes can
|
||||
// conflict (e.g. collection changed during enumeration errors) and data may change
|
||||
// unexpectedly from one mod instruction to the next.
|
||||
//
|
||||
//
|
||||
// Therefore we can just run Game1.Update here without raising any SMAPI events. There's
|
||||
// a small chance that the task will finish after we defer but before the game checks,
|
||||
// which means technically events should be raised, but the effects of missing one
|
||||
|
|
|
@ -6,7 +6,7 @@ using StardewModdingAPI.Toolkit.Serialization.Converters;
|
|||
|
||||
namespace StardewModdingAPI.Framework.Serialization
|
||||
{
|
||||
/// <summary>Handles deserialization of <see cref="PointConverter"/> for crossplatform compatibility.</summary>
|
||||
/// <summary>Handles deserialization of <see cref="Point"/> for crossplatform compatibility.</summary>
|
||||
/// <remarks>
|
||||
/// - Linux/Mac format: { "X": 1, "Y": 2 }
|
||||
/// - Windows format: "1, 2"
|
||||
|
|
|
@ -42,10 +42,12 @@ namespace StardewModdingAPI.Framework
|
|||
this.IsChanged = isChanged;
|
||||
|
||||
this.RemovedImpl.Clear();
|
||||
this.RemovedImpl.AddRange(removed);
|
||||
if (removed != null)
|
||||
this.RemovedImpl.AddRange(removed);
|
||||
|
||||
this.AddedImpl.Clear();
|
||||
this.AddedImpl.AddRange(added);
|
||||
if (added != null)
|
||||
this.AddedImpl.AddRange(added);
|
||||
}
|
||||
|
||||
/// <summary>Update the snapshot.</summary>
|
||||
|
|
|
@ -190,17 +190,9 @@ namespace StardewModdingAPI.Metadata
|
|||
|
||||
case "characters\\farmer\\farmer_base": // Farmer
|
||||
case "characters\\farmer\\farmer_base_bald":
|
||||
if (Game1.player == null || !Game1.player.IsMale)
|
||||
return false;
|
||||
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
|
||||
return true;
|
||||
|
||||
case "characters\\farmer\\farmer_girl_base": // Farmer
|
||||
case "characters\\farmer\\farmer_girl_base":
|
||||
case "characters\\farmer\\farmer_girl_base_bald":
|
||||
if (Game1.player == null || Game1.player.IsMale)
|
||||
return false;
|
||||
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
|
||||
return true;
|
||||
return this.ReloadPlayerSprites(key);
|
||||
|
||||
case "characters\\farmer\\hairstyles": // Game1.LoadContent
|
||||
FarmerRenderer.hairStylesTexture = content.Load<Texture2D>(key);
|
||||
|
@ -835,6 +827,27 @@ namespace StardewModdingAPI.Metadata
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>Reload the sprites for matching players.</summary>
|
||||
/// <param name="key">The asset key to reload.</param>
|
||||
private bool ReloadPlayerSprites(string key)
|
||||
{
|
||||
Farmer[] players =
|
||||
(
|
||||
from player in Game1.getOnlineFarmers()
|
||||
where key == this.NormalizeAssetNameIgnoringEmpty(player.getTexture())
|
||||
select player
|
||||
)
|
||||
.ToArray();
|
||||
|
||||
foreach (Farmer player in players)
|
||||
{
|
||||
this.Reflection.GetField<Dictionary<string, Dictionary<int, List<int>>>>(typeof(FarmerRenderer), "_recolorOffsets").GetValue().Remove(player.getTexture());
|
||||
player.FarmerRenderer.MarkSpriteDirty();
|
||||
}
|
||||
|
||||
return players.Any();
|
||||
}
|
||||
|
||||
/// <summary>Reload tree textures.</summary>
|
||||
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||
/// <param name="key">The asset key to reload.</param>
|
||||
|
@ -874,7 +887,11 @@ namespace StardewModdingAPI.Metadata
|
|||
|
||||
// update dialogue
|
||||
foreach (NPC villager in villagers)
|
||||
{
|
||||
villager.resetSeasonalDialogue(); // doesn't only affect seasonal dialogue
|
||||
villager.resetCurrentDialogue();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -896,18 +913,16 @@ namespace StardewModdingAPI.Metadata
|
|||
this.Reflection.GetField<bool>(villager, "_hasLoadedMasterScheduleData").SetValue(false);
|
||||
this.Reflection.GetField<Dictionary<string, string>>(villager, "_masterScheduleData").SetValue(null);
|
||||
villager.Schedule = villager.getSchedule(Game1.dayOfMonth);
|
||||
if (villager.Schedule == null)
|
||||
{
|
||||
this.Monitor.Log($"A mod set an invalid schedule for {villager.Name ?? key}, so the NPC may not behave correctly.", LogLevel.Warn);
|
||||
return true;
|
||||
}
|
||||
|
||||
// switch to new schedule if needed
|
||||
int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
|
||||
if (lastScheduleTime != 0)
|
||||
if (villager.Schedule != null)
|
||||
{
|
||||
this.Reflection.GetField<int>(villager, "scheduleTimeToTry").SetValue(9999999); // use time that's passed in to checkSchedule
|
||||
villager.checkSchedule(lastScheduleTime);
|
||||
int lastScheduleTime = villager.Schedule.Keys.Where(p => p <= Game1.timeOfDay).OrderByDescending(p => p).FirstOrDefault();
|
||||
if (lastScheduleTime != 0)
|
||||
{
|
||||
villager.scheduleTimeToTry = NPC.NO_TRY; // use time that's passed in to checkSchedule
|
||||
villager.checkSchedule(lastScheduleTime);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
|
|
@ -3,8 +3,10 @@ using System.Collections.Generic;
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using Harmony;
|
||||
using StardewModdingAPI.Framework.Exceptions;
|
||||
using StardewModdingAPI.Framework.Patching;
|
||||
using StardewValley;
|
||||
using StardewValley.Buildings;
|
||||
using StardewValley.Locations;
|
||||
|
||||
namespace StardewModdingAPI.Patches
|
||||
|
@ -63,11 +65,25 @@ namespace StardewModdingAPI.Patches
|
|||
/// <param name="gamelocations">The game locations being loaded.</param>
|
||||
/// <returns>Returns whether to execute the original method.</returns>
|
||||
private static bool Before_SaveGame_LoadDataToLocations(List<GameLocation> gamelocations)
|
||||
{
|
||||
bool removedAny =
|
||||
LoadErrorPatch.RemoveInvalidLocations(gamelocations)
|
||||
| LoadErrorPatch.RemoveBrokenBuildings(gamelocations)
|
||||
| LoadErrorPatch.RemoveInvalidNpcs(gamelocations);
|
||||
|
||||
if (removedAny)
|
||||
LoadErrorPatch.OnContentRemoved();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Remove locations which don't exist in-game.</summary>
|
||||
/// <param name="locations">The current game locations.</param>
|
||||
private static bool RemoveInvalidLocations(List<GameLocation> locations)
|
||||
{
|
||||
bool removedAny = false;
|
||||
|
||||
// remove invalid locations
|
||||
foreach (GameLocation location in gamelocations.ToArray())
|
||||
foreach (GameLocation location in locations.ToArray())
|
||||
{
|
||||
if (location is Cellar)
|
||||
continue; // missing cellars will be added by the game code
|
||||
|
@ -75,23 +91,48 @@ namespace StardewModdingAPI.Patches
|
|||
if (Game1.getLocationFromName(location.name) == null)
|
||||
{
|
||||
LoadErrorPatch.Monitor.Log($"Removed invalid location '{location.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom location mod?)", LogLevel.Warn);
|
||||
gamelocations.Remove(location);
|
||||
locations.Remove(location);
|
||||
removedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
// get building interiors
|
||||
var interiors =
|
||||
(
|
||||
from location in gamelocations.OfType<BuildableGameLocation>()
|
||||
from building in location.buildings
|
||||
where building.indoors.Value != null
|
||||
select building.indoors.Value
|
||||
);
|
||||
return removedAny;
|
||||
}
|
||||
|
||||
/// <summary>Remove buildings which don't exist in the game data.</summary>
|
||||
/// <param name="locations">The current game locations.</param>
|
||||
private static bool RemoveBrokenBuildings(IEnumerable<GameLocation> locations)
|
||||
{
|
||||
bool removedAny = false;
|
||||
|
||||
foreach (BuildableGameLocation location in locations.OfType<BuildableGameLocation>())
|
||||
{
|
||||
foreach (Building building in location.buildings.ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
BluePrint _ = new BluePrint(building.buildingType.Value);
|
||||
}
|
||||
catch (SContentLoadException)
|
||||
{
|
||||
LoadErrorPatch.Monitor.Log($"Removed invalid building type '{building.buildingType.Value}' in {location.Name} ({building.tileX}, {building.tileY}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom building mod?)", LogLevel.Warn);
|
||||
location.buildings.Remove(building);
|
||||
removedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removedAny;
|
||||
}
|
||||
|
||||
/// <summary>Remove NPCs which don't exist in the game data.</summary>
|
||||
/// <param name="locations">The current game locations.</param>
|
||||
private static bool RemoveInvalidNpcs(IEnumerable<GameLocation> locations)
|
||||
{
|
||||
bool removedAny = false;
|
||||
|
||||
// remove custom NPCs which no longer exist
|
||||
IDictionary<string, string> data = Game1.content.Load<Dictionary<string, string>>("Data\\NPCDispositions");
|
||||
foreach (GameLocation location in gamelocations.Concat(interiors))
|
||||
foreach (GameLocation location in LoadErrorPatch.GetAllLocations(locations))
|
||||
{
|
||||
foreach (NPC npc in location.characters.ToArray())
|
||||
{
|
||||
|
@ -103,7 +144,7 @@ namespace StardewModdingAPI.Patches
|
|||
}
|
||||
catch
|
||||
{
|
||||
LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
|
||||
LoadErrorPatch.Monitor.Log($"Removed invalid villager '{npc.Name}' in {location.Name} ({npc.getTileLocation()}) to avoid a crash when loading save '{Constants.SaveFolderName}'. (Did you remove a custom NPC mod?)", LogLevel.Warn);
|
||||
location.characters.Remove(npc);
|
||||
removedAny = true;
|
||||
}
|
||||
|
@ -111,10 +152,22 @@ namespace StardewModdingAPI.Patches
|
|||
}
|
||||
}
|
||||
|
||||
if (removedAny)
|
||||
LoadErrorPatch.OnContentRemoved();
|
||||
return removedAny;
|
||||
}
|
||||
|
||||
return true;
|
||||
/// <summary>Get all locations, including building interiors.</summary>
|
||||
/// <param name="locations">The main game locations.</param>
|
||||
private static IEnumerable<GameLocation> GetAllLocations(IEnumerable<GameLocation> locations)
|
||||
{
|
||||
foreach (GameLocation location in locations)
|
||||
{
|
||||
yield return location;
|
||||
if (location is BuildableGameLocation buildableLocation)
|
||||
{
|
||||
foreach (GameLocation interior in buildableLocation.buildings.Select(p => p.indoors.Value).Where(p => p != null))
|
||||
yield return interior;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Reflection;
|
||||
using Harmony;
|
||||
using StardewModdingAPI.Framework.Patching;
|
||||
using StardewValley;
|
||||
|
||||
namespace StardewModdingAPI.Patches
|
||||
{
|
||||
/// <summary>A Harmony patch for <see cref="NPC.parseMasterSchedule"/> which intercepts crashes due to invalid schedule data.</summary>
|
||||
/// <remarks>Patch methods must be static for Harmony to work correctly. See the Harmony documentation before renaming patch arguments.</remarks>
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
|
||||
[SuppressMessage("ReSharper", "IdentifierTypo", Justification = "Argument names are defined by Harmony and methods are named for clarity.")]
|
||||
internal class ScheduleErrorPatch : IHarmonyPatch
|
||||
{
|
||||
/*********
|
||||
** Fields
|
||||
*********/
|
||||
/// <summary>Writes messages to the console and log file on behalf of the game.</summary>
|
||||
private static IMonitor MonitorForGame;
|
||||
|
||||
/// <summary>Whether the target is currently being intercepted.</summary>
|
||||
private static bool IsIntercepting;
|
||||
|
||||
|
||||
/*********
|
||||
** Accessors
|
||||
*********/
|
||||
/// <summary>A unique name for this patch.</summary>
|
||||
public string Name => nameof(ScheduleErrorPatch);
|
||||
|
||||
|
||||
/*********
|
||||
** Public methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="monitorForGame">Writes messages to the console and log file on behalf of the game.</param>
|
||||
public ScheduleErrorPatch(IMonitor monitorForGame)
|
||||
{
|
||||
ScheduleErrorPatch.MonitorForGame = monitorForGame;
|
||||
}
|
||||
|
||||
/// <summary>Apply the Harmony patch.</summary>
|
||||
/// <param name="harmony">The Harmony instance.</param>
|
||||
public void Apply(HarmonyInstance harmony)
|
||||
{
|
||||
harmony.Patch(
|
||||
original: AccessTools.Method(typeof(NPC), "parseMasterSchedule"),
|
||||
prefix: new HarmonyMethod(this.GetType(), nameof(ScheduleErrorPatch.Before_NPC_parseMasterSchedule))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>The method to call instead of <see cref="NPC.parseMasterSchedule"/>.</summary>
|
||||
/// <param name="rawData">The raw schedule data to parse.</param>
|
||||
/// <param name="__instance">The instance being patched.</param>
|
||||
/// <param name="__result">The patched method's return value.</param>
|
||||
/// <param name="__originalMethod">The method being wrapped.</param>
|
||||
/// <returns>Returns whether to execute the original method.</returns>
|
||||
private static bool Before_NPC_parseMasterSchedule(string rawData, NPC __instance, ref Dictionary<int, SchedulePathDescription> __result, MethodInfo __originalMethod)
|
||||
{
|
||||
if (ScheduleErrorPatch.IsIntercepting)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
ScheduleErrorPatch.IsIntercepting = true;
|
||||
__result = (Dictionary<int, SchedulePathDescription>)__originalMethod.Invoke(__instance, new object[] { rawData });
|
||||
return false;
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
ScheduleErrorPatch.MonitorForGame.Log($"Failed parsing schedule for NPC {__instance.Name}:\n{rawData}\n{ex.InnerException ?? ex}", LogLevel.Error);
|
||||
__result = new Dictionary<int, SchedulePathDescription>();
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ScheduleErrorPatch.IsIntercepting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,6 +11,7 @@ using StardewModdingAPI.Framework;
|
|||
using StardewModdingAPI.Toolkit.Utilities;
|
||||
|
||||
[assembly: InternalsVisibleTo("SMAPI.Tests")]
|
||||
[assembly: InternalsVisibleTo("ConsoleCommands")] // for performance monitoring commands
|
||||
[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] // Moq for unit testing
|
||||
namespace StardewModdingAPI
|
||||
{
|
||||
|
|
|
@ -6,6 +6,12 @@ This file contains advanced configuration for SMAPI. You generally shouldn't cha
|
|||
The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to log custom changes.
|
||||
|
||||
|
||||
This file is overwritten each time you update or reinstall SMAPI. To avoid losing custom settings,
|
||||
create a 'config.user.json' file in the same folder with *only* the settings you want to change.
|
||||
That file won't be overwritten, and any settings in it will override the default options. Don't
|
||||
copy all the settings, or you may cause bugs due to overridden changes in future SMAPI versions.
|
||||
|
||||
|
||||
|
||||
*/
|
||||
{
|
||||
|
|
|
@ -39,18 +39,36 @@ namespace StardewModdingAPI
|
|||
/// <param name="majorVersion">The major version incremented for major API changes.</param>
|
||||
/// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
|
||||
/// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
|
||||
/// <param name="prerelease">An optional prerelease tag.</param>
|
||||
/// <param name="build">Optional build metadata. This is ignored when determining version precedence.</param>
|
||||
/// <param name="prereleaseTag">An optional prerelease tag.</param>
|
||||
/// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
|
||||
public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prereleaseTag = null, string buildMetadata = null)
|
||||
: this(majorVersion, minorVersion, patchVersion, 0, prereleaseTag, buildMetadata) { }
|
||||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="majorVersion">The major version incremented for major API changes.</param>
|
||||
/// <param name="minorVersion">The minor version incremented for backwards-compatible changes.</param>
|
||||
/// <param name="patchVersion">The patch version for backwards-compatible bug fixes.</param>
|
||||
/// <param name="prereleaseTag">An optional prerelease tag.</param>
|
||||
/// <param name="platformRelease">The platform-specific version (if applicable).</param>
|
||||
/// <param name="buildMetadata">Optional build metadata. This is ignored when determining version precedence.</param>
|
||||
[JsonConstructor]
|
||||
public SemanticVersion(int majorVersion, int minorVersion, int patchVersion, string prerelease = null, string build = null)
|
||||
: this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, prerelease, build)) { }
|
||||
internal SemanticVersion(int majorVersion, int minorVersion, int patchVersion, int platformRelease, string prereleaseTag = null, string buildMetadata = null)
|
||||
: this(new Toolkit.SemanticVersion(majorVersion, minorVersion, patchVersion, platformRelease, prereleaseTag, buildMetadata)) { }
|
||||
|
||||
/// <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 SemanticVersion(string version)
|
||||
: this(new Toolkit.SemanticVersion(version)) { }
|
||||
: this(version, allowNonStandard: false) { }
|
||||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="version">The semantic version string.</param>
|
||||
/// <param name="allowNonStandard">Whether to recognize non-standard semver extensions.</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>
|
||||
internal SemanticVersion(string version, bool allowNonStandard)
|
||||
: this(new Toolkit.SemanticVersion(version, allowNonStandard)) { }
|
||||
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="version">The assembly version.</param>
|
||||
|
@ -141,6 +159,12 @@ namespace StardewModdingAPI
|
|||
return this.Version.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Whether the version uses non-standard extensions, like four-part game versions on some platforms.</summary>
|
||||
public bool IsNonStandard()
|
||||
{
|
||||
return this.Version.IsNonStandard();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"warn.invalid-content-removed": "Le contenu non valide a été supprimé afin d'éviter un plantage (voir la console de SMAPI pour plus d'informations)."
|
||||
}
|
Loading…
Reference in New Issue