Merge branch 'develop' into stable
This commit is contained in:
commit
f976b5c0f0
|
@ -18,6 +18,9 @@ _ReSharper*/
|
||||||
*.[Rr]e[Ss]harper
|
*.[Rr]e[Ss]harper
|
||||||
*.DotSettings.user
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# Rider
|
||||||
|
.idea/
|
||||||
|
|
||||||
# NuGet packages
|
# NuGet packages
|
||||||
*.nupkg
|
*.nupkg
|
||||||
**/packages/*
|
**/packages/*
|
||||||
|
@ -31,4 +34,4 @@ appsettings.Development.json
|
||||||
src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json
|
src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json
|
||||||
|
|
||||||
# Azure generated files
|
# Azure generated files
|
||||||
src/SMAPI.Web/Properties/PublishProfiles/smapi-web-release - Web Deploy.pubxml
|
src/SMAPI.Web/Properties/PublishProfiles/*.pubxml
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
<!--set properties -->
|
<!--set properties -->
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>3.0.1</Version>
|
<Version>3.1.0</Version>
|
||||||
<Product>SMAPI</Product>
|
<Product>SMAPI</Product>
|
||||||
|
|
||||||
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
|
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
|
||||||
|
|
|
@ -68,9 +68,9 @@ French | ❑ not translated
|
||||||
German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
|
German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
|
||||||
Hungarian | ❑ not translated
|
Hungarian | ❑ not translated
|
||||||
Italian | ❑ not translated
|
Italian | ❑ not translated
|
||||||
Japanese | ❑ not translated
|
Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json)
|
||||||
Korean | ❑ not translated
|
Korean | ❑ not translated
|
||||||
Portuguese | ❑ not translated
|
Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json)
|
||||||
Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json)
|
Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json)
|
||||||
Spanish | ❑ not translated
|
Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json)
|
||||||
Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json)
|
Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json)
|
||||||
|
|
|
@ -1,8 +1,52 @@
|
||||||
← [README](README.md)
|
← [README](README.md)
|
||||||
|
|
||||||
# Release notes
|
# Release notes
|
||||||
|
## 3.1
|
||||||
|
Released 05 January 2019 for Stardew Valley 1.4 or later.
|
||||||
|
|
||||||
|
* For players:
|
||||||
|
* Added separate group in 'skipped mods' list for broken dependencies, so it's easier to see what to fix first.
|
||||||
|
* Added friendly log message for save file-not-found errors.
|
||||||
|
* Updated for gamepad modes in Stardew Valley 1.4.1.
|
||||||
|
* Improved performance in some cases.
|
||||||
|
* 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 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.
|
||||||
|
* File uploads now expire after one month.
|
||||||
|
* Updated the JSON validator for Content Patcher 1.10 and 1.11.
|
||||||
|
* Fixed JSON validator no longer letting you change format when viewing a file.
|
||||||
|
* Fixed JSON validator for Content Patcher not requiring `Default` if `AllowBlank` was omitted.
|
||||||
|
* Fixed log parser not correctly handling content packs with no author (thanks to danvolchek!).
|
||||||
|
* Fixed main sidebar link pointing to wiki instead of home page.
|
||||||
|
|
||||||
|
* For modders:
|
||||||
|
* Added `World.ChestInventoryChanged` event (thanks to collaboration with wartech0!).
|
||||||
|
* Added asset propagation for...
|
||||||
|
* grass textures;
|
||||||
|
* winter flooring textures;
|
||||||
|
* `Data\Bundles` changes (for added bundles only);
|
||||||
|
* `Characters\Farmer\farmer_girl_base_bald`.
|
||||||
|
* Added paranoid-mode warning for direct `Console` access.
|
||||||
|
* Improved error messages for `TargetParameterCountException` when using the reflection API.
|
||||||
|
* `helper.Read/WriteSaveData` can now be used while a save is being loaded (e.g. within a `Specialized.LoadStageChanged` event).
|
||||||
|
* Removed `DumpMetadata` option. It was only for specific debugging cases, but players would sometimes enable it incorrectly and then report crashes.
|
||||||
|
* Fixed private textures loaded from content packs not having their `Name` field set.
|
||||||
|
|
||||||
|
* For SMAPI developers:
|
||||||
|
* You can now run local environments without configuring Amazon, Azure, MongoDB, and Pastebin accounts.
|
||||||
|
|
||||||
## 3.0.1
|
## 3.0.1
|
||||||
Released 02 December 2019 for Stardew Valley 1.4.0.1.
|
Released 02 December 2019 for Stardew Valley 1.4 or later.
|
||||||
|
|
||||||
* For players:
|
* For players:
|
||||||
* Updated for Stardew Valley 1.4.0.1.
|
* Updated for Stardew Valley 1.4.0.1.
|
||||||
|
|
|
@ -40,7 +40,7 @@ property | description
|
||||||
`$(GamePath)` | The absolute path to the detected game folder.
|
`$(GamePath)` | The absolute path to the detected game folder.
|
||||||
`$(GameExecutableName)` | The game's executable name for the current OS (`Stardew Valley` on Windows, or `StardewValley` on Linux/Mac).
|
`$(GameExecutableName)` | The game's executable name for the current OS (`Stardew Valley` on Windows, or `StardewValley` on Linux/Mac).
|
||||||
|
|
||||||
If you get a build error saying it can't find your game, see [_set the game path_](#set-the-game-path).
|
If you get a build error saying it can't find your game, see [_custom game path_](#custom-game-path).
|
||||||
|
|
||||||
### Add assembly references
|
### Add assembly references
|
||||||
The package adds assembly references to SMAPI, Stardew Valley, xTile, and MonoGame (Linux/Mac) or XNA
|
The package adds assembly references to SMAPI, Stardew Valley, xTile, and MonoGame (Linux/Mac) or XNA
|
||||||
|
@ -228,7 +228,7 @@ or you have multiple installs, you can specify the path yourself. There's two wa
|
||||||
</Project>
|
</Project>
|
||||||
```
|
```
|
||||||
|
|
||||||
4. Replace `PATH_HERE` with your game path.
|
4. Replace `PATH_HERE` with your game's folder path.
|
||||||
|
|
||||||
* **Option 2: path in the project file.**
|
* **Option 2: path in the project file.**
|
||||||
_You'll need to do this for each project that uses the package._
|
_You'll need to do this for each project that uses the package._
|
||||||
|
|
|
@ -71,14 +71,14 @@ flag | purpose
|
||||||
### Compiling from source
|
### Compiling from source
|
||||||
Using an official SMAPI release is recommended for most users.
|
Using an official SMAPI release is recommended for most users.
|
||||||
|
|
||||||
SMAPI uses some C# 7 code, so you'll need at least
|
SMAPI often uses the latest C# syntax. You may need the latest version of
|
||||||
[Visual Studio 2017](https://www.visualstudio.com/vs/community/) on Windows,
|
[Visual Studio](https://www.visualstudio.com/vs/community/) on Windows,
|
||||||
[MonoDevelop 7.0](https://www.monodevelop.com/) on Linux,
|
[MonoDevelop](https://www.monodevelop.com/) on Linux,
|
||||||
[Visual Studio 2017 for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent
|
[Visual Studio for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent IDE
|
||||||
IDE to compile it. It uses build configuration derived from the
|
to compile it. It uses build configuration derived from the
|
||||||
[crossplatform mod config](https://github.com/Pathoschild/Stardew.ModBuildConfig#readme) to detect
|
[crossplatform mod config](https://smapi.io/package/readme) to detect your current OS automatically
|
||||||
your current OS automatically and load the correct references. Compile output will be placed in a
|
and load the correct references. Compile output will be placed in a `bin` folder at the root of the
|
||||||
`bin` folder at the root of the git repository.
|
git repository.
|
||||||
|
|
||||||
### Debugging a local build
|
### Debugging a local build
|
||||||
Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting
|
Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting
|
||||||
|
|
|
@ -10,17 +10,21 @@ and update check API.
|
||||||
* [Short URLs](#short-urls)
|
* [Short URLs](#short-urls)
|
||||||
* [For SMAPI developers](#for-smapi-developers)
|
* [For SMAPI developers](#for-smapi-developers)
|
||||||
* [Local development](#local-development)
|
* [Local development](#local-development)
|
||||||
* [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
|
* [Production environment](#production-environment)
|
||||||
|
|
||||||
## Log parser
|
## Log parser
|
||||||
The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are
|
The log parser at https://smapi.io/log provides a web UI for uploading, parsing, and sharing SMAPI
|
||||||
persisted in a compressed form to Pastebin. The log parser lives at https://smapi.io/log.
|
logs.
|
||||||
|
|
||||||
|
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
|
||||||
|
|
||||||
## JSON validator
|
## JSON validator
|
||||||
### Overview
|
### Overview
|
||||||
The JSON validator provides a web UI for uploading and sharing JSON files, and validating them as
|
The JSON validator at https://smapi.io/json provides a web UI for uploading and sharing JSON files,
|
||||||
plain JSON or against a predefined format like `manifest.json` or Content Patcher's `content.json`.
|
and validating them as plain JSON or against a predefined format like `manifest.json` or Content
|
||||||
The JSON validator lives at https://smapi.io/json.
|
Patcher's `content.json`.
|
||||||
|
|
||||||
|
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
|
||||||
|
|
||||||
### Schema file format
|
### Schema file format
|
||||||
Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/)
|
Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/)
|
||||||
|
@ -336,43 +340,44 @@ short url | → | target page
|
||||||
A local environment lets you run a complete copy of the web project (including cache database) on
|
A local environment lets you run a complete copy of the web project (including cache database) on
|
||||||
your machine, with no external dependencies aside from the actual mod sites.
|
your machine, with no external dependencies aside from the actual mod sites.
|
||||||
|
|
||||||
Initial setup:
|
1. Enter the Nexus credentials in `appsettings.Development.json` . You can leave the other
|
||||||
|
credentials empty to default to fetching data anonymously, and storing data in-memory and
|
||||||
1. [Install MongoDB](https://docs.mongodb.com/manual/administration/install-community/) and add its
|
on disk.
|
||||||
`bin` folder to the system PATH.
|
|
||||||
2. Create a local folder for the MongoDB data (e.g. `C:\dev\smapi-cache`).
|
|
||||||
3. Enter your credentials in the `appsettings.Development.json` file. You can leave the MongoDB
|
|
||||||
credentials as-is to use the default local instance; see the next section for the other settings.
|
|
||||||
|
|
||||||
To launch the environment:
|
|
||||||
1. Launch MongoDB from a terminal (change the data path if applicable):
|
|
||||||
```sh
|
|
||||||
mongod --dbpath C:\dev\smapi-cache
|
|
||||||
```
|
|
||||||
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
|
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
|
||||||
<small>(Local URLs will use HTTP instead of HTTPS.)</small>
|
|
||||||
|
|
||||||
### Production environment
|
### Production environment
|
||||||
A production environment includes the web servers and cache database hosted online for public
|
A production environment includes the web servers and cache database hosted online for public
|
||||||
access. This section assumes you're creating a new production environment from scratch (not using
|
access.
|
||||||
the official live environment).
|
|
||||||
|
This section assumes you're creating a new environment on Azure, but the app isn't tied to any
|
||||||
|
Azure services. If you want to host it on a different site, you'll need to adjust the instructions
|
||||||
|
accordingly.
|
||||||
|
|
||||||
Initial setup:
|
Initial setup:
|
||||||
|
|
||||||
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)).
|
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas))
|
||||||
2. Create an AWS Beanstalk .NET environment with these environment properties:
|
for mod data.
|
||||||
|
2. Create an Azure Blob storage account for uploaded files.
|
||||||
|
3. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
|
||||||
|
4. Add these application settings in the new App Services environment:
|
||||||
|
|
||||||
property name | description
|
property name | description
|
||||||
------------------------------- | -----------------
|
------------------------------- | -----------------
|
||||||
`LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API.
|
`ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2.
|
||||||
`LogParser:PastebinUserKey` | The [Pastebin user key](https://pastebin.com/api#8) used to authenticate with the Pastebin API. Can be left blank to post anonymously.
|
`ApiClients.GitHubUsername`<br />`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info. If these are omitted, GitHub will impose much stricter rate limits.
|
||||||
`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info.
|
`ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client).
|
||||||
`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info.
|
`MongoDB:ConnectionString` | The connection string for the MongoDB instance.
|
||||||
`MongoDB:Host` | The hostname for the MongoDB instance.
|
`MongoDB:Database` | The MongoDB database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
|
||||||
`MongoDB:Username` | The login username for the MongoDB instance.
|
|
||||||
`MongoDB:Password` | The login password for the MongoDB instance.
|
Optional settings:
|
||||||
`MongoDB:Database` | The database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
|
|
||||||
|
property name | description
|
||||||
|
------------------------------- | -----------------
|
||||||
|
`BackgroundServices:Enabled` | Set to `true` to enable background processes like fetching data from the wiki, or false to disable them.
|
||||||
|
`Site:BetaEnabled` | Set to `true` to show a separate download button if there's a beta version of SMAPI in its GitHub releases.
|
||||||
|
`Site:BetaBlurb` | If `Site:BetaEnabled` is true and there's a beta version of SMAPI in its GitHub releases, this is shown on the beta download button as explanatory subtext.
|
||||||
|
`Site:SupporterList` | A list of Patreon supports to credit on the download page.
|
||||||
|
|
||||||
To deploy updates:
|
To deploy updates:
|
||||||
1. Deploy the web project using [AWS Toolkit for Visual Studio](https://aws.amazon.com/visualstudio/).
|
1. [Deploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure).
|
||||||
2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.)
|
2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.)
|
||||||
|
|
|
@ -61,8 +61,8 @@ else
|
||||||
COMMAND="type"
|
COMMAND="type"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# select terminal (prefer $TERMINAL for overrides and testing, then xterm for best compatibility, then known supported terminals)
|
# select terminal (prefer xterm for best compatibility, then known supported terminals)
|
||||||
for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty x-terminal-emulator; do
|
for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator; do
|
||||||
if $COMMAND "$terminal" 2>/dev/null; then
|
if $COMMAND "$terminal" 2>/dev/null; then
|
||||||
# Find the true shell behind x-terminal-emulator
|
# Find the true shell behind x-terminal-emulator
|
||||||
if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then
|
if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then
|
||||||
|
@ -108,7 +108,7 @@ else
|
||||||
alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*'
|
alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*'
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
xterm|xfce4-terminal|gnome-terminal|terminal|termite)
|
xterm|xfce4-terminal|gnome-terminal|terminal|termite|mate-terminal)
|
||||||
$LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'"
|
$LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'"
|
||||||
;;
|
;;
|
||||||
konsole)
|
konsole)
|
||||||
|
|
|
@ -46,12 +46,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
|
||||||
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, () => new Pan());
|
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, () => new Pan());
|
||||||
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, () => new Wand());
|
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, () => new Wand());
|
||||||
|
|
||||||
|
// clothing
|
||||||
|
foreach (int id in Game1.clothingInformation.Keys)
|
||||||
|
yield return this.TryCreate(ItemType.Clothing, id, () => new Clothing(id));
|
||||||
|
|
||||||
// wallpapers
|
// wallpapers
|
||||||
for (int id = 0; id < 112; id++)
|
for (int id = 0; id < 112; id++)
|
||||||
yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory });
|
yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory });
|
||||||
|
|
||||||
// flooring
|
// flooring
|
||||||
for (int id = 0; id < 40; id++)
|
for (int id = 0; id < 56; id++)
|
||||||
yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory });
|
yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory });
|
||||||
|
|
||||||
// equipment
|
// equipment
|
||||||
|
@ -59,11 +63,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
|
||||||
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
|
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
|
||||||
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
|
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
|
||||||
yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id));
|
yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id));
|
||||||
foreach (int id in Game1.objectInformation.Keys)
|
|
||||||
{
|
|
||||||
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
|
|
||||||
yield return this.TryCreate(ItemType.Ring, id, () => new Ring(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// weapons
|
// weapons
|
||||||
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
|
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
|
||||||
|
@ -87,101 +86,91 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
|
||||||
foreach (int id in Game1.bigCraftablesInformation.Keys)
|
foreach (int id in Game1.bigCraftablesInformation.Keys)
|
||||||
yield return this.TryCreate(ItemType.BigCraftable, id, () => new SObject(Vector2.Zero, id));
|
yield return this.TryCreate(ItemType.BigCraftable, id, () => new SObject(Vector2.Zero, id));
|
||||||
|
|
||||||
// secret notes
|
|
||||||
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
|
|
||||||
{
|
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + id, () =>
|
|
||||||
{
|
|
||||||
SObject note = new SObject(79, 1);
|
|
||||||
note.name = $"{note.name} #{id}";
|
|
||||||
return note;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// objects
|
// objects
|
||||||
foreach (int id in Game1.objectInformation.Keys)
|
foreach (int id in Game1.objectInformation.Keys)
|
||||||
{
|
{
|
||||||
if (id == 79)
|
string[] fields = Game1.objectInformation[id]?.Split('/');
|
||||||
continue; // secret note handled above
|
|
||||||
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
|
|
||||||
continue; // handled separated
|
|
||||||
|
|
||||||
// spawn main item
|
// secret notes
|
||||||
SObject item;
|
if (id == 79)
|
||||||
{
|
{
|
||||||
SearchableItem main = this.TryCreate(ItemType.Object, id, () => id == 812
|
foreach (int secretNoteId in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
|
||||||
|
{
|
||||||
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, () =>
|
||||||
|
{
|
||||||
|
SObject note = new SObject(79, 1);
|
||||||
|
note.name = $"{note.name} #{secretNoteId}";
|
||||||
|
return note;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ring
|
||||||
|
else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring
|
||||||
|
yield return this.TryCreate(ItemType.Ring, id, () => new Ring(id));
|
||||||
|
|
||||||
|
// item
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// spawn main item
|
||||||
|
SObject item = null;
|
||||||
|
yield return this.TryCreate(ItemType.Object, id, () =>
|
||||||
|
{
|
||||||
|
return item = (id == 812 // roe
|
||||||
? new ColoredObject(id, 1, Color.White)
|
? new ColoredObject(id, 1, Color.White)
|
||||||
: new SObject(id, 1)
|
: new SObject(id, 1)
|
||||||
);
|
);
|
||||||
yield return main;
|
});
|
||||||
item = main?.Item as SObject;
|
|
||||||
}
|
|
||||||
if (item == null)
|
if (item == null)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
// flavored items
|
||||||
|
switch (item.Category)
|
||||||
|
{
|
||||||
// fruit products
|
// fruit products
|
||||||
if (item.Category == SObject.FruitsCategory)
|
case SObject.FruitsCategory:
|
||||||
{
|
|
||||||
// wine
|
// wine
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () =>
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () => new SObject(348, 1)
|
||||||
{
|
|
||||||
SObject wine = new SObject(348, 1)
|
|
||||||
{
|
{
|
||||||
Name = $"{item.Name} Wine",
|
Name = $"{item.Name} Wine",
|
||||||
Price = item.Price * 3
|
Price = item.Price * 3,
|
||||||
};
|
preserve = { SObject.PreserveType.Wine },
|
||||||
wine.preserve.Value = SObject.PreserveType.Wine;
|
preservedParentSheetIndex = { item.ParentSheetIndex }
|
||||||
wine.preservedParentSheetIndex.Value = item.ParentSheetIndex;
|
|
||||||
return wine;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// jelly
|
// jelly
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () =>
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () => new SObject(344, 1)
|
||||||
{
|
|
||||||
SObject jelly = new SObject(344, 1)
|
|
||||||
{
|
{
|
||||||
Name = $"{item.Name} Jelly",
|
Name = $"{item.Name} Jelly",
|
||||||
Price = 50 + item.Price * 2
|
Price = 50 + item.Price * 2,
|
||||||
};
|
preserve = { SObject.PreserveType.Jelly },
|
||||||
jelly.preserve.Value = SObject.PreserveType.Jelly;
|
preservedParentSheetIndex = { item.ParentSheetIndex }
|
||||||
jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex;
|
|
||||||
return jelly;
|
|
||||||
});
|
});
|
||||||
}
|
break;
|
||||||
|
|
||||||
// vegetable products
|
// vegetable products
|
||||||
else if (item.Category == SObject.VegetableCategory)
|
case SObject.VegetableCategory:
|
||||||
{
|
|
||||||
// juice
|
// juice
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () =>
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () => new SObject(350, 1)
|
||||||
{
|
|
||||||
SObject juice = new SObject(350, 1)
|
|
||||||
{
|
{
|
||||||
Name = $"{item.Name} Juice",
|
Name = $"{item.Name} Juice",
|
||||||
Price = (int)(item.Price * 2.25d)
|
Price = (int)(item.Price * 2.25d),
|
||||||
};
|
preserve = { SObject.PreserveType.Juice },
|
||||||
juice.preserve.Value = SObject.PreserveType.Juice;
|
preservedParentSheetIndex = { item.ParentSheetIndex }
|
||||||
juice.preservedParentSheetIndex.Value = item.ParentSheetIndex;
|
|
||||||
return juice;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// pickled
|
// pickled
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => new SObject(342, 1)
|
||||||
{
|
|
||||||
SObject pickled = new SObject(342, 1)
|
|
||||||
{
|
{
|
||||||
Name = $"Pickled {item.Name}",
|
Name = $"Pickled {item.Name}",
|
||||||
Price = 50 + item.Price * 2
|
Price = 50 + item.Price * 2,
|
||||||
};
|
preserve = { SObject.PreserveType.Pickle },
|
||||||
pickled.preserve.Value = SObject.PreserveType.Pickle;
|
preservedParentSheetIndex = { item.ParentSheetIndex }
|
||||||
pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex;
|
|
||||||
return pickled;
|
|
||||||
});
|
});
|
||||||
}
|
break;
|
||||||
|
|
||||||
// flower honey
|
// flower honey
|
||||||
else if (item.Category == SObject.flowersCategory)
|
case SObject.flowersCategory:
|
||||||
{
|
|
||||||
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
|
||||||
{
|
{
|
||||||
SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
|
SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
|
||||||
|
@ -192,43 +181,47 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
|
||||||
honey.Price += item.Price * 2;
|
honey.Price += item.Price * 2;
|
||||||
return honey;
|
return honey;
|
||||||
});
|
});
|
||||||
}
|
break;
|
||||||
|
|
||||||
// roe and aged roe (derived from FishPond.GetFishProduce)
|
// roe and aged roe (derived from FishPond.GetFishProduce)
|
||||||
else if (id == 812)
|
case SObject.sellAtFishShopCategory when id == 812:
|
||||||
{
|
|
||||||
foreach (var pair in Game1.objectInformation)
|
foreach (var pair in Game1.objectInformation)
|
||||||
{
|
{
|
||||||
// get input
|
// get input
|
||||||
SObject input = new SObject(pair.Key, 1);
|
SObject input = this.TryCreate(ItemType.Object, -1, () => new SObject(pair.Key, 1))?.Item as SObject;
|
||||||
if (input.Category != SObject.FishCategory)
|
if (input == null || input.Category != SObject.FishCategory)
|
||||||
continue;
|
continue;
|
||||||
Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange;
|
Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange;
|
||||||
|
|
||||||
// yield roe
|
// yield roe
|
||||||
SObject roe = new ColoredObject(812, 1, color)
|
SObject roe = null;
|
||||||
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () =>
|
||||||
|
{
|
||||||
|
roe = new ColoredObject(812, 1, color)
|
||||||
{
|
{
|
||||||
name = $"{input.Name} Roe",
|
name = $"{input.Name} Roe",
|
||||||
preserve = { Value = SObject.PreserveType.Roe },
|
preserve = { Value = SObject.PreserveType.Roe },
|
||||||
preservedParentSheetIndex = { Value = input.ParentSheetIndex }
|
preservedParentSheetIndex = { Value = input.ParentSheetIndex }
|
||||||
};
|
};
|
||||||
roe.Price += input.Price / 2;
|
roe.Price += input.Price / 2;
|
||||||
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, roe);
|
return roe;
|
||||||
|
});
|
||||||
|
|
||||||
// aged roe
|
// aged roe
|
||||||
if (pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
|
if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
|
||||||
{
|
{
|
||||||
ColoredObject agedRoe = new ColoredObject(447, 1, color)
|
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () => new ColoredObject(447, 1, color)
|
||||||
{
|
{
|
||||||
name = $"Aged {input.Name} Roe",
|
name = $"Aged {input.Name} Roe",
|
||||||
Category = -27,
|
Category = -27,
|
||||||
preserve = { Value = SObject.PreserveType.AgedRoe },
|
preserve = { Value = SObject.PreserveType.AgedRoe },
|
||||||
preservedParentSheetIndex = { Value = input.ParentSheetIndex },
|
preservedParentSheetIndex = { Value = input.ParentSheetIndex },
|
||||||
Price = roe.Price * 2
|
Price = roe.Price * 2
|
||||||
};
|
});
|
||||||
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, agedRoe);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"Name": "Console Commands",
|
"Name": "Console Commands",
|
||||||
"Author": "SMAPI",
|
"Author": "SMAPI",
|
||||||
"Version": "3.0.1",
|
"Version": "3.1.0",
|
||||||
"Description": "Adds SMAPI console commands that let you manipulate the game.",
|
"Description": "Adds SMAPI console commands that let you manipulate the game.",
|
||||||
"UniqueID": "SMAPI.ConsoleCommands",
|
"UniqueID": "SMAPI.ConsoleCommands",
|
||||||
"EntryDll": "ConsoleCommands.dll",
|
"EntryDll": "ConsoleCommands.dll",
|
||||||
"MinimumApiVersion": "3.0.1"
|
"MinimumApiVersion": "3.1.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
{
|
{
|
||||||
"Name": "Save Backup",
|
"Name": "Save Backup",
|
||||||
"Author": "SMAPI",
|
"Author": "SMAPI",
|
||||||
"Version": "3.0.1",
|
"Version": "3.1.0",
|
||||||
"Description": "Automatically backs up all your saves once per day into its folder.",
|
"Description": "Automatically backs up all your saves once per day into its folder.",
|
||||||
"UniqueID": "SMAPI.SaveBackup",
|
"UniqueID": "SMAPI.SaveBackup",
|
||||||
"EntryDll": "SaveBackup.dll",
|
"EntryDll": "SaveBackup.dll",
|
||||||
"MinimumApiVersion": "3.0.1"
|
"MinimumApiVersion": "3.1.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -27,10 +27,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
|
||||||
/// <summary>The mod has no update keys set.</summary>
|
/// <summary>The mod has no update keys set.</summary>
|
||||||
NoUpdateKeys = 32,
|
NoUpdateKeys = 32,
|
||||||
|
|
||||||
|
/// <summary>Uses .NET APIs for reading and writing to the console.</summary>
|
||||||
|
AccessesConsole = 64,
|
||||||
|
|
||||||
/// <summary>Uses .NET APIs for filesystem access.</summary>
|
/// <summary>Uses .NET APIs for filesystem access.</summary>
|
||||||
AccessesFilesystem = 64,
|
AccessesFilesystem = 128,
|
||||||
|
|
||||||
/// <summary>Uses .NET APIs for shell or process access.</summary>
|
/// <summary>Uses .NET APIs for shell or process access.</summary>
|
||||||
AccessesShell = 128
|
AccessesShell = 256
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,25 +105,29 @@ namespace StardewModdingAPI.Toolkit.Utilities
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool IsRunningAndroid()
|
private static bool IsRunningAndroid()
|
||||||
{
|
{
|
||||||
using (Process process = new Process())
|
using Process process = new Process
|
||||||
{
|
{
|
||||||
process.StartInfo.FileName = "getprop";
|
StartInfo =
|
||||||
process.StartInfo.Arguments = "ro.build.user";
|
{
|
||||||
process.StartInfo.RedirectStandardOutput = true;
|
FileName = "getprop",
|
||||||
process.StartInfo.UseShellExecute = false;
|
Arguments = "ro.build.user",
|
||||||
process.StartInfo.CreateNoWindow = true;
|
RedirectStandardOutput = true,
|
||||||
|
UseShellExecute = false,
|
||||||
|
CreateNoWindow = true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
process.Start();
|
process.Start();
|
||||||
string output = process.StandardOutput.ReadToEnd();
|
string output = process.StandardOutput.ReadToEnd();
|
||||||
return !string.IsNullOrEmpty(output);
|
return !string.IsNullOrWhiteSpace(output);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Detect whether the code is running on Mac.</summary>
|
/// <summary>Detect whether the code is running on Mac.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|
|
@ -16,7 +16,6 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
{
|
{
|
||||||
/// <summary>Provides an info/download page about SMAPI.</summary>
|
/// <summary>Provides an info/download page about SMAPI.</summary>
|
||||||
[Route("")]
|
[Route("")]
|
||||||
[Route("install")]
|
|
||||||
internal class IndexController : Controller
|
internal class IndexController : Controller
|
||||||
{
|
{
|
||||||
/*********
|
/*********
|
||||||
|
@ -72,7 +71,7 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// render view
|
// render view
|
||||||
var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb);
|
var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb, this.SiteConfig.SupporterList);
|
||||||
return this.View(model);
|
return this.View(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,8 +9,7 @@ using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
using Newtonsoft.Json.Schema;
|
using Newtonsoft.Json.Schema;
|
||||||
using StardewModdingAPI.Web.Framework;
|
using StardewModdingAPI.Web.Framework;
|
||||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
using StardewModdingAPI.Web.Framework.Compression;
|
|
||||||
using StardewModdingAPI.Web.ViewModels.JsonValidator;
|
using StardewModdingAPI.Web.ViewModels.JsonValidator;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Controllers
|
namespace StardewModdingAPI.Web.Controllers
|
||||||
|
@ -21,11 +20,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Fields
|
** Fields
|
||||||
*********/
|
*********/
|
||||||
/// <summary>The underlying Pastebin client.</summary>
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
private readonly IPastebinClient Pastebin;
|
private readonly IStorageProvider Storage;
|
||||||
|
|
||||||
/// <summary>The underlying text compression helper.</summary>
|
|
||||||
private readonly IGzipHelper GzipHelper;
|
|
||||||
|
|
||||||
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
|
||||||
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
|
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
|
||||||
|
@ -49,20 +45,18 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
** Constructor
|
** Constructor
|
||||||
***/
|
***/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="pastebin">The Pastebin API client.</param>
|
/// <param name="storage">Provides access to raw data storage.</param>
|
||||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
public JsonValidatorController(IStorageProvider storage)
|
||||||
public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper)
|
|
||||||
{
|
{
|
||||||
this.Pastebin = pastebin;
|
this.Storage = storage;
|
||||||
this.GzipHelper = gzipHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
** Web UI
|
** Web UI
|
||||||
***/
|
***/
|
||||||
/// <summary>Render the schema validator UI.</summary>
|
/// <summary>Render the schema validator UI.</summary>
|
||||||
/// <param name="schemaName">The schema name with which to validate the JSON.</param>
|
/// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param>
|
||||||
/// <param name="id">The paste ID.</param>
|
/// <param name="id">The stored file ID.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("json")]
|
[Route("json")]
|
||||||
[Route("json/{schemaName}")]
|
[Route("json/{schemaName}")]
|
||||||
|
@ -76,16 +70,20 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return this.View("Index", result);
|
return this.View("Index", result);
|
||||||
|
|
||||||
// fetch raw JSON
|
// fetch raw JSON
|
||||||
PasteInfo paste = await this.GetAsync(id);
|
StoredFileInfo file = await this.Storage.GetAsync(id);
|
||||||
if (string.IsNullOrWhiteSpace(paste.Content))
|
if (string.IsNullOrWhiteSpace(file.Content))
|
||||||
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
|
return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
|
||||||
result.SetContent(paste.Content);
|
result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning);
|
||||||
|
|
||||||
|
// skip parsing if we're going to the edit screen
|
||||||
|
if (schemaName?.ToLower() == "edit")
|
||||||
|
return this.View("Index", result);
|
||||||
|
|
||||||
// parse JSON
|
// parse JSON
|
||||||
JToken parsed;
|
JToken parsed;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
parsed = JToken.Parse(paste.Content, new JsonLoadSettings
|
parsed = JToken.Parse(file.Content, new JsonLoadSettings
|
||||||
{
|
{
|
||||||
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
|
||||||
CommentHandling = CommentHandling.Load
|
CommentHandling = CommentHandling.Load
|
||||||
|
@ -97,7 +95,7 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
}
|
}
|
||||||
|
|
||||||
// format JSON
|
// format JSON
|
||||||
result.SetContent(parsed.ToString(Formatting.Indented));
|
result.SetContent(parsed.ToString(Formatting.Indented), expiry: file.Expiry, uploadWarning: file.Warning);
|
||||||
|
|
||||||
// skip if no schema selected
|
// skip if no schema selected
|
||||||
if (schemaName == "none")
|
if (schemaName == "none")
|
||||||
|
@ -132,23 +130,20 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
|
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
|
||||||
{
|
{
|
||||||
if (request == null)
|
if (request == null)
|
||||||
return this.View("Index", new JsonValidatorModel(null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid."));
|
return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid."));
|
||||||
|
|
||||||
// normalize schema name
|
// normalize schema name
|
||||||
string schemaName = this.NormalizeSchemaName(request.SchemaName);
|
string schemaName = this.NormalizeSchemaName(request.SchemaName);
|
||||||
|
|
||||||
// get raw log text
|
// get raw text
|
||||||
string input = request.Content;
|
string input = request.Content;
|
||||||
if (string.IsNullOrWhiteSpace(input))
|
if (string.IsNullOrWhiteSpace(input))
|
||||||
return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty."));
|
return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
|
||||||
|
|
||||||
// upload log
|
// upload file
|
||||||
input = this.GzipHelper.CompressString(input);
|
UploadResult result = await this.Storage.SaveAsync(input);
|
||||||
SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input);
|
if (!result.Succeeded)
|
||||||
|
return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
|
||||||
// handle errors
|
|
||||||
if (!result.Success)
|
|
||||||
return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
|
|
||||||
|
|
||||||
// redirect to view
|
// redirect to view
|
||||||
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
|
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
|
||||||
|
@ -158,13 +153,12 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Private methods
|
** Private methods
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Fetch raw text from Pastebin.</summary>
|
/// <summary>Build a JSON validator model.</summary>
|
||||||
/// <param name="id">The Pastebin paste ID.</param>
|
/// <param name="pasteID">The stored file ID.</param>
|
||||||
private async Task<PasteInfo> GetAsync(string id)
|
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
||||||
|
private JsonValidatorModel GetModel(string pasteID, string schemaName)
|
||||||
{
|
{
|
||||||
PasteInfo response = await this.Pastebin.GetAsync(id);
|
return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats);
|
||||||
response.Content = this.GzipHelper.DecompressString(response.Content);
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
|
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
|
||||||
|
|
|
@ -1,22 +1,12 @@
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Amazon;
|
|
||||||
using Amazon.Runtime;
|
|
||||||
using Amazon.S3;
|
|
||||||
using Amazon.S3.Model;
|
|
||||||
using Amazon.S3.Transfer;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using StardewModdingAPI.Toolkit.Utilities;
|
using StardewModdingAPI.Toolkit.Utilities;
|
||||||
using StardewModdingAPI.Web.Framework;
|
using StardewModdingAPI.Web.Framework;
|
||||||
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
|
||||||
using StardewModdingAPI.Web.Framework.Compression;
|
|
||||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
|
||||||
using StardewModdingAPI.Web.Framework.LogParsing;
|
using StardewModdingAPI.Web.Framework.LogParsing;
|
||||||
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
using StardewModdingAPI.Web.Framework.LogParsing.Models;
|
||||||
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
using StardewModdingAPI.Web.ViewModels;
|
using StardewModdingAPI.Web.ViewModels;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Controllers
|
namespace StardewModdingAPI.Web.Controllers
|
||||||
|
@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Fields
|
** Fields
|
||||||
*********/
|
*********/
|
||||||
/// <summary>The API client settings.</summary>
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
private readonly ApiClientsConfig ClientsConfig;
|
private readonly IStorageProvider Storage;
|
||||||
|
|
||||||
/// <summary>The underlying Pastebin client.</summary>
|
|
||||||
private readonly IPastebinClient Pastebin;
|
|
||||||
|
|
||||||
/// <summary>The underlying text compression helper.</summary>
|
|
||||||
private readonly IGzipHelper GzipHelper;
|
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
|
@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
** Constructor
|
** Constructor
|
||||||
***/
|
***/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="clientsConfig">The API client settings.</param>
|
/// <param name="storage">Provides access to raw data storage.</param>
|
||||||
/// <param name="pastebin">The Pastebin API client.</param>
|
public LogParserController(IStorageProvider storage)
|
||||||
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
|
||||||
public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
|
|
||||||
{
|
{
|
||||||
this.ClientsConfig = clientsConfig.Value;
|
this.Storage = storage;
|
||||||
this.Pastebin = pastebin;
|
|
||||||
this.GzipHelper = gzipHelper;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
** Web UI
|
** Web UI
|
||||||
***/
|
***/
|
||||||
/// <summary>Render the log parser UI.</summary>
|
/// <summary>Render the log parser UI.</summary>
|
||||||
/// <param name="id">The paste ID.</param>
|
/// <param name="id">The stored file ID.</param>
|
||||||
/// <param name="raw">Whether to display the raw unparsed log.</param>
|
/// <param name="raw">Whether to display the raw unparsed log.</param>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("log")]
|
[Route("log")]
|
||||||
|
@ -70,12 +50,12 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return this.View("Index", this.GetModel(id));
|
return this.View("Index", this.GetModel(id));
|
||||||
|
|
||||||
// log page
|
// log page
|
||||||
PasteInfo paste = await this.GetAsync(id);
|
StoredFileInfo file = await this.Storage.GetAsync(id);
|
||||||
ParsedLog log = paste.Success
|
ParsedLog log = file.Success
|
||||||
? new LogParser().Parse(paste.Content)
|
? new LogParser().Parse(file.Content)
|
||||||
: new ParsedLog { IsValid = false, Error = paste.Error };
|
: new ParsedLog { IsValid = false, Error = file.Error };
|
||||||
|
|
||||||
return this.View("Index", this.GetModel(id, uploadWarning: paste.Warning, expiry: paste.Expiry).SetResult(log, raw));
|
return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, expiry: file.Expiry).SetResult(log, raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
/***
|
/***
|
||||||
|
@ -92,8 +72,7 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
|
return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
|
||||||
|
|
||||||
// upload log
|
// upload log
|
||||||
input = this.GzipHelper.CompressString(input);
|
UploadResult uploadResult = await this.Storage.SaveAsync(input);
|
||||||
var uploadResult = await this.TrySaveLog(input);
|
|
||||||
if (!uploadResult.Succeeded)
|
if (!uploadResult.Succeeded)
|
||||||
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
|
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
|
||||||
|
|
||||||
|
@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
/*********
|
/*********
|
||||||
** Private methods
|
** Private methods
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Fetch raw text from Pastebin.</summary>
|
|
||||||
/// <param name="id">The Pastebin paste ID.</param>
|
|
||||||
private async Task<PasteInfo> GetAsync(string id)
|
|
||||||
{
|
|
||||||
// get from Amazon S3
|
|
||||||
if (Guid.TryParseExact(id, "N", out Guid _))
|
|
||||||
{
|
|
||||||
var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
|
|
||||||
|
|
||||||
using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using (GetObjectResponse response = await s3.GetObjectAsync(this.ClientsConfig.AmazonLogBucket, $"logs/{id}"))
|
|
||||||
using (Stream responseStream = response.ResponseStream)
|
|
||||||
using (StreamReader reader = new StreamReader(responseStream))
|
|
||||||
{
|
|
||||||
DateTime expiry = response.Expiration.ExpiryDateUtc;
|
|
||||||
string pastebinError = response.Metadata["x-amz-meta-pastebin-error"];
|
|
||||||
string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
|
|
||||||
|
|
||||||
return new PasteInfo
|
|
||||||
{
|
|
||||||
Success = true,
|
|
||||||
Content = content,
|
|
||||||
Expiry = expiry,
|
|
||||||
Warning = pastebinError
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (AmazonServiceException ex)
|
|
||||||
{
|
|
||||||
return ex.ErrorCode == "NoSuchKey"
|
|
||||||
? new PasteInfo { Error = "There's no log with that ID." }
|
|
||||||
: new PasteInfo { Error = $"Could not fetch that log from AWS S3 ({ex.ErrorCode}: {ex.Message})." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// get from PasteBin
|
|
||||||
else
|
|
||||||
{
|
|
||||||
PasteInfo response = await this.Pastebin.GetAsync(id);
|
|
||||||
response.Content = this.GzipHelper.DecompressString(response.Content);
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Save a log to Pastebin or Amazon S3, if available.</summary>
|
|
||||||
/// <param name="content">The content to upload.</param>
|
|
||||||
/// <returns>Returns metadata about the save attempt.</returns>
|
|
||||||
private async Task<UploadResult> TrySaveLog(string content)
|
|
||||||
{
|
|
||||||
// save to PasteBin
|
|
||||||
string uploadError;
|
|
||||||
{
|
|
||||||
SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", content);
|
|
||||||
if (result.Success)
|
|
||||||
return new UploadResult(true, result.ID, null);
|
|
||||||
|
|
||||||
uploadError = $"Pastebin error: {result.Error ?? "unknown error"}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// fallback to S3
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
|
|
||||||
|
|
||||||
using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content)))
|
|
||||||
using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
|
|
||||||
using (TransferUtility uploader = new TransferUtility(s3))
|
|
||||||
{
|
|
||||||
string id = Guid.NewGuid().ToString("N");
|
|
||||||
|
|
||||||
var uploadRequest = new TransferUtilityUploadRequest
|
|
||||||
{
|
|
||||||
BucketName = this.ClientsConfig.AmazonLogBucket,
|
|
||||||
Key = $"logs/{id}",
|
|
||||||
InputStream = stream,
|
|
||||||
Metadata =
|
|
||||||
{
|
|
||||||
// note: AWS will lowercase keys and prefix 'x-amz-meta-'
|
|
||||||
["smapi-uploaded"] = DateTime.UtcNow.ToString("O"),
|
|
||||||
["pastebin-error"] = uploadError
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
await uploader.UploadAsync(uploadRequest);
|
|
||||||
|
|
||||||
return new UploadResult(true, id, uploadError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new UploadResult(false, null, $"{uploadError}\n{ex.Message}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Build a log parser model.</summary>
|
/// <summary>Build a log parser model.</summary>
|
||||||
/// <param name="pasteID">The paste ID.</param>
|
/// <param name="pasteID">The stored file ID.</param>
|
||||||
/// <param name="expiry">When the uploaded file will no longer be available.</param>
|
/// <param name="expiry">When the uploaded file will no longer be available.</param>
|
||||||
/// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
|
/// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
|
||||||
/// <param name="uploadError">An error which occurred while uploading the log.</param>
|
/// <param name="uploadError">An error which occurred while uploading the log.</param>
|
||||||
|
@ -243,36 +124,5 @@ namespace StardewModdingAPI.Web.Controllers
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The result of an attempt to upload a file.</summary>
|
|
||||||
private class UploadResult
|
|
||||||
{
|
|
||||||
/*********
|
|
||||||
** Accessors
|
|
||||||
*********/
|
|
||||||
/// <summary>Whether the file upload succeeded.</summary>
|
|
||||||
public bool Succeeded { get; }
|
|
||||||
|
|
||||||
/// <summary>The file ID, if applicable.</summary>
|
|
||||||
public string ID { get; }
|
|
||||||
|
|
||||||
/// <summary>The upload error, if any.</summary>
|
|
||||||
public string UploadError { get; }
|
|
||||||
|
|
||||||
|
|
||||||
/*********
|
|
||||||
** Public methods
|
|
||||||
*********/
|
|
||||||
/// <summary>Construct an instance.</summary>
|
|
||||||
/// <param name="succeeded">Whether the file upload succeeded.</param>
|
|
||||||
/// <param name="id">The file ID, if applicable.</param>
|
|
||||||
/// <param name="uploadError">The upload error, if any.</param>
|
|
||||||
public UploadResult(bool succeeded, string id, string uploadError)
|
|
||||||
{
|
|
||||||
this.Succeeded = succeeded;
|
|
||||||
this.ID = id;
|
|
||||||
this.UploadError = uploadError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,10 +9,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
/// <summary>Fetch a saved paste.</summary>
|
/// <summary>Fetch a saved paste.</summary>
|
||||||
/// <param name="id">The paste ID.</param>
|
/// <param name="id">The paste ID.</param>
|
||||||
Task<PasteInfo> GetAsync(string id);
|
Task<PasteInfo> GetAsync(string id);
|
||||||
|
|
||||||
/// <summary>Save a paste to Pastebin.</summary>
|
|
||||||
/// <param name="name">The paste name.</param>
|
|
||||||
/// <param name="content">The paste content.</param>
|
|
||||||
Task<SavePasteResult> PostAsync(string name, string content);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
{
|
{
|
||||||
/// <summary>The response for a get-paste request.</summary>
|
/// <summary>The response for a get-paste request.</summary>
|
||||||
|
@ -11,12 +9,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
/// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
|
/// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
|
||||||
public string Content { get; set; }
|
public string Content { get; set; }
|
||||||
|
|
||||||
/// <summary>When the file will no longer be available.</summary>
|
|
||||||
public DateTime? Expiry { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary>
|
|
||||||
public string Warning { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The error message if saving failed.</summary>
|
/// <summary>The error message if saving failed.</summary>
|
||||||
public string Error { get; set; }
|
public string Error { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Http;
|
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Pathoschild.Http.Client;
|
using Pathoschild.Http.Client;
|
||||||
|
|
||||||
|
@ -16,12 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
/// <summary>The underlying HTTP client.</summary>
|
/// <summary>The underlying HTTP client.</summary>
|
||||||
private readonly IClient Client;
|
private readonly IClient Client;
|
||||||
|
|
||||||
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
|
|
||||||
private readonly string UserKey;
|
|
||||||
|
|
||||||
/// <summary>The developer key used to authenticate with the Pastebin API.</summary>
|
|
||||||
private readonly string DevKey;
|
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Public methods
|
** Public methods
|
||||||
|
@ -29,13 +21,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="baseUrl">The base URL for the Pastebin API.</param>
|
/// <param name="baseUrl">The base URL for the Pastebin API.</param>
|
||||||
/// <param name="userAgent">The user agent for the API client.</param>
|
/// <param name="userAgent">The user agent for the API client.</param>
|
||||||
/// <param name="userKey">The user key used to authenticate with the Pastebin API.</param>
|
public PastebinClient(string baseUrl, string userAgent)
|
||||||
/// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param>
|
|
||||||
public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey)
|
|
||||||
{
|
{
|
||||||
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
|
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
|
||||||
this.UserKey = userKey;
|
|
||||||
this.DevKey = devKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Fetch a saved paste.</summary>
|
/// <summary>Fetch a saved paste.</summary>
|
||||||
|
@ -66,50 +54,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Save a paste to Pastebin.</summary>
|
|
||||||
/// <param name="name">The paste name.</param>
|
|
||||||
/// <param name="content">The paste content.</param>
|
|
||||||
public async Task<SavePasteResult> PostAsync(string name, string content)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// validate
|
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
|
||||||
return new SavePasteResult { Error = "The log content can't be empty." };
|
|
||||||
|
|
||||||
// post to API
|
|
||||||
string response = await this.Client
|
|
||||||
.PostAsync("api/api_post.php")
|
|
||||||
.WithBody(p => p.FormUrlEncoded(new
|
|
||||||
{
|
|
||||||
api_option = "paste",
|
|
||||||
api_user_key = this.UserKey,
|
|
||||||
api_dev_key = this.DevKey,
|
|
||||||
api_paste_private = 1, // unlisted
|
|
||||||
api_paste_name = name,
|
|
||||||
api_paste_expire_date = "N", // never expire
|
|
||||||
api_paste_code = content
|
|
||||||
}))
|
|
||||||
.AsString();
|
|
||||||
|
|
||||||
// handle Pastebin errors
|
|
||||||
if (string.IsNullOrWhiteSpace(response))
|
|
||||||
return new SavePasteResult { Error = "Received an empty response from Pastebin." };
|
|
||||||
if (response.StartsWith("Bad API request"))
|
|
||||||
return new SavePasteResult { Error = response };
|
|
||||||
if (!response.Contains("/"))
|
|
||||||
return new SavePasteResult { Error = $"Received an unknown response: {response}" };
|
|
||||||
|
|
||||||
// return paste ID
|
|
||||||
string pastebinID = response.Split("/").Last();
|
|
||||||
return new SavePasteResult { Success = true, ID = pastebinID };
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
return new SavePasteResult { Success = false, Error = ex.ToString() };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
|
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
|
|
||||||
{
|
|
||||||
/// <summary>The response for a save-log request.</summary>
|
|
||||||
internal class SavePasteResult
|
|
||||||
{
|
|
||||||
/// <summary>Whether the log was successfully saved.</summary>
|
|
||||||
public bool Success { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The saved paste ID (if <see cref="Success"/> is <c>true</c>).</summary>
|
|
||||||
public string ID { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The error message (if saving failed).</summary>
|
|
||||||
public string Error { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,19 +14,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
** Amazon Web Services
|
** Azure
|
||||||
****/
|
****/
|
||||||
/// <summary>The access key for AWS authentication.</summary>
|
/// <summary>The connection string for the Azure Blob storage account.</summary>
|
||||||
public string AmazonAccessKey { get; set; }
|
public string AzureBlobConnectionString { get; set; }
|
||||||
|
|
||||||
/// <summary>The secret key for AWS authentication.</summary>
|
/// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary>
|
||||||
public string AmazonSecretKey { get; set; }
|
public string AzureBlobTempContainer { get; set; }
|
||||||
|
|
||||||
/// <summary>The AWS region endpoint (like 'us-east-1').</summary>
|
/// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary>
|
||||||
public string AmazonRegion { get; set; }
|
public int AzureBlobTempExpiryDays { get; set; }
|
||||||
|
|
||||||
/// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
|
|
||||||
public string AmazonLogBucket { get; set; }
|
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
|
@ -61,6 +58,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
/// <summary>The password with which to authenticate to the GitHub API (if any).</summary>
|
/// <summary>The password with which to authenticate to the GitHub API (if any).</summary>
|
||||||
public string GitHubPassword { get; set; }
|
public string GitHubPassword { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
** ModDrop
|
** ModDrop
|
||||||
****/
|
****/
|
||||||
|
@ -70,6 +68,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
/// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary>
|
/// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary>
|
||||||
public string ModDropModPageUrl { get; set; }
|
public string ModDropModPageUrl { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
** Nexus Mods
|
** Nexus Mods
|
||||||
****/
|
****/
|
||||||
|
@ -85,17 +84,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
/// <summary>The Nexus API authentication key.</summary>
|
/// <summary>The Nexus API authentication key.</summary>
|
||||||
public string NexusApiKey { get; set; }
|
public string NexusApiKey { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/****
|
/****
|
||||||
** Pastebin
|
** Pastebin
|
||||||
****/
|
****/
|
||||||
/// <summary>The base URL for the Pastebin API.</summary>
|
/// <summary>The base URL for the Pastebin API.</summary>
|
||||||
public string PastebinBaseUrl { get; set; }
|
public string PastebinBaseUrl { get; set; }
|
||||||
|
|
||||||
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
|
|
||||||
public string PastebinUserKey { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The developer key used to authenticate with the Pastebin API.</summary>
|
|
||||||
public string PastebinDevKey { get; set; }
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web.Framework.ConfigModels
|
namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
{
|
{
|
||||||
/// <summary>The config settings for mod compatibility list.</summary>
|
/// <summary>The config settings for mod compatibility list.</summary>
|
||||||
|
@ -8,14 +6,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
/*********
|
/*********
|
||||||
** Accessors
|
** Accessors
|
||||||
*********/
|
*********/
|
||||||
/// <summary>The MongoDB hostname.</summary>
|
/// <summary>The MongoDB connection string.</summary>
|
||||||
public string Host { get; set; }
|
public string ConnectionString { get; set; }
|
||||||
|
|
||||||
/// <summary>The MongoDB username (if any).</summary>
|
|
||||||
public string Username { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The MongoDB password (if any).</summary>
|
|
||||||
public string Password { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The database name.</summary>
|
/// <summary>The database name.</summary>
|
||||||
public string Database { get; set; }
|
public string Database { get; set; }
|
||||||
|
@ -24,15 +16,10 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
/*********
|
/*********
|
||||||
** Public method
|
** Public method
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Get the MongoDB connection string.</summary>
|
/// <summary>Get whether a MongoDB instance is configured.</summary>
|
||||||
public string GetConnectionString()
|
public bool IsConfigured()
|
||||||
{
|
{
|
||||||
bool isLocal = this.Host == "localhost";
|
return !string.IsNullOrWhiteSpace(this.ConnectionString);
|
||||||
bool hasLogin = !string.IsNullOrWhiteSpace(this.Username) && !string.IsNullOrWhiteSpace(this.Password);
|
|
||||||
|
|
||||||
return $"mongodb{(isLocal ? "" : "+srv")}://"
|
|
||||||
+ (hasLogin ? $"{Uri.EscapeDataString(this.Username)}:{Uri.EscapeDataString(this.Password)}@" : "")
|
|
||||||
+ $"{this.Host}/{this.Database}?retryWrites=true&w=majority";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
|
|
||||||
/// <summary>A short sentence shown under the beta download button, if any.</summary>
|
/// <summary>A short sentence shown under the beta download button, if any.</summary>
|
||||||
public string BetaBlurb { get; set; }
|
public string BetaBlurb { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A list of supports to credit on the main page, in Markdown format.</summary>
|
||||||
|
public string SupporterList { get; set; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
|
using System;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
@ -12,8 +14,9 @@ namespace StardewModdingAPI.Web.Framework
|
||||||
/// <param name="action">The name of the action method.</param>
|
/// <param name="action">The name of the action method.</param>
|
||||||
/// <param name="controller">The name of the controller.</param>
|
/// <param name="controller">The name of the controller.</param>
|
||||||
/// <param name="values">An object that contains route values.</param>
|
/// <param name="values">An object that contains route values.</param>
|
||||||
|
/// <param name="absoluteUrl">Get an absolute URL instead of a server-relative path/</param>
|
||||||
/// <returns>The generated URL.</returns>
|
/// <returns>The generated URL.</returns>
|
||||||
public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null)
|
public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false)
|
||||||
{
|
{
|
||||||
RouteValueDictionary valuesDict = new RouteValueDictionary(values);
|
RouteValueDictionary valuesDict = new RouteValueDictionary(values);
|
||||||
foreach (var value in helper.ActionContext.RouteData.Values)
|
foreach (var value in helper.ActionContext.RouteData.Values)
|
||||||
|
@ -22,7 +25,14 @@ namespace StardewModdingAPI.Web.Framework
|
||||||
valuesDict[value.Key] = null; // explicitly remove it from the URL
|
valuesDict[value.Key] = null; // explicitly remove it from the URL
|
||||||
}
|
}
|
||||||
|
|
||||||
return helper.Action(action, controller, valuesDict);
|
string url = helper.Action(action, controller, valuesDict);
|
||||||
|
if (absoluteUrl)
|
||||||
|
{
|
||||||
|
HttpRequest request = helper.ActionContext.HttpContext.Request;
|
||||||
|
Uri baseUri = new Uri($"{request.Scheme}://{request.Host}");
|
||||||
|
url = new Uri(baseUri, url).ToString();
|
||||||
|
}
|
||||||
|
return url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
|
||||||
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
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>
|
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
|
||||||
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
/// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary>
|
/// <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);
|
private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Web.Framework.Storage
|
||||||
|
{
|
||||||
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
|
internal interface IStorageProvider
|
||||||
|
{
|
||||||
|
/// <summary>Save a text file to storage.</summary>
|
||||||
|
/// <param name="content">The content to upload.</param>
|
||||||
|
/// <param name="compress">Whether to gzip the text.</param>
|
||||||
|
/// <returns>Returns metadata about the save attempt.</returns>
|
||||||
|
Task<UploadResult> SaveAsync(string content, bool compress = true);
|
||||||
|
|
||||||
|
/// <summary>Fetch raw text from storage.</summary>
|
||||||
|
/// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
|
||||||
|
Task<StoredFileInfo> GetAsync(string id);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,181 @@
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Azure;
|
||||||
|
using Azure.Storage.Blobs;
|
||||||
|
using Azure.Storage.Blobs.Models;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||||
|
using StardewModdingAPI.Web.Framework.Compression;
|
||||||
|
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Web.Framework.Storage
|
||||||
|
{
|
||||||
|
/// <summary>Provides access to raw data storage.</summary>
|
||||||
|
internal class StorageProvider : IStorageProvider
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Fields
|
||||||
|
*********/
|
||||||
|
/// <summary>The API client settings.</summary>
|
||||||
|
private readonly ApiClientsConfig ClientsConfig;
|
||||||
|
|
||||||
|
/// <summary>The underlying Pastebin client.</summary>
|
||||||
|
private readonly IPastebinClient Pastebin;
|
||||||
|
|
||||||
|
/// <summary>The underlying text compression helper.</summary>
|
||||||
|
private readonly IGzipHelper GzipHelper;
|
||||||
|
|
||||||
|
/// <summary>Whether Azure blob storage is configured.</summary>
|
||||||
|
private bool HasAzure => !string.IsNullOrWhiteSpace(this.ClientsConfig.AzureBlobConnectionString);
|
||||||
|
|
||||||
|
/// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary>
|
||||||
|
private int ExpiryDays => this.ClientsConfig.AzureBlobTempExpiryDays;
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="clientsConfig">The API client settings.</param>
|
||||||
|
/// <param name="pastebin">The underlying Pastebin client.</param>
|
||||||
|
/// <param name="gzipHelper">The underlying text compression helper.</param>
|
||||||
|
public StorageProvider(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
|
||||||
|
{
|
||||||
|
this.ClientsConfig = clientsConfig.Value;
|
||||||
|
this.Pastebin = pastebin;
|
||||||
|
this.GzipHelper = gzipHelper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Save a text file to storage.</summary>
|
||||||
|
/// <param name="content">The content to upload.</param>
|
||||||
|
/// <param name="compress">Whether to gzip the text.</param>
|
||||||
|
/// <returns>Returns metadata about the save attempt.</returns>
|
||||||
|
public async Task<UploadResult> SaveAsync(string content, bool compress = true)
|
||||||
|
{
|
||||||
|
string id = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
// save to Azure
|
||||||
|
if (this.HasAzure)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||||
|
BlobClient blob = this.GetAzureBlobClient(id);
|
||||||
|
await blob.UploadAsync(stream);
|
||||||
|
|
||||||
|
return new UploadResult(true, id, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new UploadResult(false, null, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// save to local filesystem for testing
|
||||||
|
else
|
||||||
|
{
|
||||||
|
string path = this.GetDevFilePath(id);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path));
|
||||||
|
|
||||||
|
File.WriteAllText(path, content);
|
||||||
|
return new UploadResult(true, id, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fetch raw text from storage.</summary>
|
||||||
|
/// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
|
||||||
|
public async Task<StoredFileInfo> GetAsync(string id)
|
||||||
|
{
|
||||||
|
// fetch from blob storage
|
||||||
|
if (Guid.TryParseExact(id, "N", out Guid _))
|
||||||
|
{
|
||||||
|
// Azure Blob storage
|
||||||
|
if (this.HasAzure)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
BlobClient blob = this.GetAzureBlobClient(id);
|
||||||
|
Response<BlobDownloadInfo> response = await blob.DownloadAsync();
|
||||||
|
using BlobDownloadInfo result = response.Value;
|
||||||
|
|
||||||
|
using StreamReader reader = new StreamReader(result.Content);
|
||||||
|
DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ExpiryDays);
|
||||||
|
string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
|
||||||
|
|
||||||
|
return new StoredFileInfo
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Content = content,
|
||||||
|
Expiry = expiry.UtcDateTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (RequestFailedException ex)
|
||||||
|
{
|
||||||
|
return new StoredFileInfo
|
||||||
|
{
|
||||||
|
Error = ex.ErrorCode == "BlobNotFound"
|
||||||
|
? "There's no file with that ID."
|
||||||
|
: $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// local filesystem for testing
|
||||||
|
else
|
||||||
|
{
|
||||||
|
FileInfo file = new FileInfo(this.GetDevFilePath(id));
|
||||||
|
if (file.Exists)
|
||||||
|
{
|
||||||
|
if (file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow)
|
||||||
|
file.Delete();
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return new StoredFileInfo
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Content = File.ReadAllText(file.FullName),
|
||||||
|
Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays),
|
||||||
|
Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new StoredFileInfo
|
||||||
|
{
|
||||||
|
Error = "There's no file with that ID."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get from Pastebin
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PasteInfo response = await this.Pastebin.GetAsync(id);
|
||||||
|
response.Content = this.GzipHelper.DecompressString(response.Content);
|
||||||
|
return new StoredFileInfo
|
||||||
|
{
|
||||||
|
Success = response.Success,
|
||||||
|
Content = response.Content,
|
||||||
|
Error = response.Error
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a client for reading and writing to Azure Blob storage.</summary>
|
||||||
|
/// <param name="id">The file ID.</param>
|
||||||
|
private BlobClient GetAzureBlobClient(string id)
|
||||||
|
{
|
||||||
|
var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString);
|
||||||
|
var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer);
|
||||||
|
return container.GetBlobClient($"uploads/{id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get the absolute file path for an upload when running in a local test environment with no Azure account configured.</summary>
|
||||||
|
/// <param name="id">The file ID.</param>
|
||||||
|
private string GetDevFilePath(string id)
|
||||||
|
{
|
||||||
|
return Path.Combine(Path.GetTempPath(), "smapi-web-temp", $"{id}.txt");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
using System;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Web.Framework.Storage
|
||||||
|
{
|
||||||
|
/// <summary>The response for a get-file request.</summary>
|
||||||
|
internal class StoredFileInfo
|
||||||
|
{
|
||||||
|
/// <summary>Whether the file was successfully fetched.</summary>
|
||||||
|
public bool Success { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary>
|
||||||
|
public string Content { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the file will no longer be available.</summary>
|
||||||
|
public DateTime? Expiry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary>
|
||||||
|
public string Warning { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The error message if saving failed.</summary>
|
||||||
|
public string Error { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
namespace StardewModdingAPI.Web.Framework.Storage
|
||||||
|
{
|
||||||
|
/// <summary>The result of an attempt to upload a file.</summary>
|
||||||
|
internal class UploadResult
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>Whether the file upload succeeded.</summary>
|
||||||
|
public bool Succeeded { get; }
|
||||||
|
|
||||||
|
/// <summary>The file ID, if applicable.</summary>
|
||||||
|
public string ID { get; }
|
||||||
|
|
||||||
|
/// <summary>The upload error, if any.</summary>
|
||||||
|
public string UploadError { get; }
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="succeeded">Whether the file upload succeeded.</param>
|
||||||
|
/// <param name="id">The file ID, if applicable.</param>
|
||||||
|
/// <param name="uploadError">The upload error, if any.</param>
|
||||||
|
public UploadResult(bool succeeded, string id, string uploadError)
|
||||||
|
{
|
||||||
|
this.Succeeded = succeeded;
|
||||||
|
this.ID = id;
|
||||||
|
this.UploadError = uploadError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,8 +12,9 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="AWSSDK.S3" Version="3.3.108.4" />
|
<PackageReference Include="Azure.Storage.Blobs" Version="12.1.0" />
|
||||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" />
|
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" />
|
||||||
|
<PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" />
|
||||||
<PackageReference Include="Hangfire.Mongo" Version="0.6.5" />
|
<PackageReference Include="Hangfire.Mongo" Version="0.6.5" />
|
||||||
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
|
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
|
||||||
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
|
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
|
||||||
|
@ -23,6 +24,7 @@
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" 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.Rewrite" Version="2.2.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" 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="MongoDB.Driver" Version="2.9.3" />
|
||||||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
|
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
|
||||||
<PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
|
<PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
|
using Hangfire.MemoryStorage;
|
||||||
using Hangfire.Mongo;
|
using Hangfire.Mongo;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
@ -7,6 +9,8 @@ using Microsoft.AspNetCore.Rewrite;
|
||||||
using Microsoft.AspNetCore.Routing;
|
using Microsoft.AspNetCore.Routing;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Mongo2Go;
|
||||||
using MongoDB.Bson.Serialization;
|
using MongoDB.Bson.Serialization;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
@ -24,6 +28,7 @@ using StardewModdingAPI.Web.Framework.Clients.Pastebin;
|
||||||
using StardewModdingAPI.Web.Framework.Compression;
|
using StardewModdingAPI.Web.Framework.Compression;
|
||||||
using StardewModdingAPI.Web.Framework.ConfigModels;
|
using StardewModdingAPI.Web.Framework.ConfigModels;
|
||||||
using StardewModdingAPI.Web.Framework.RewriteRules;
|
using StardewModdingAPI.Web.Framework.RewriteRules;
|
||||||
|
using StardewModdingAPI.Web.Framework.Storage;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Web
|
namespace StardewModdingAPI.Web
|
||||||
{
|
{
|
||||||
|
@ -87,10 +92,20 @@ namespace StardewModdingAPI.Web
|
||||||
}
|
}
|
||||||
|
|
||||||
// init MongoDB
|
// init MongoDB
|
||||||
|
services.AddSingleton<MongoDbRunner>(serv => !mongoConfig.IsConfigured()
|
||||||
|
? MongoDbRunner.Start()
|
||||||
|
: throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.")
|
||||||
|
);
|
||||||
services.AddSingleton<IMongoDatabase>(serv =>
|
services.AddSingleton<IMongoDatabase>(serv =>
|
||||||
{
|
{
|
||||||
|
// get connection string
|
||||||
|
string connectionString = mongoConfig.IsConfigured()
|
||||||
|
? mongoConfig.ConnectionString
|
||||||
|
: serv.GetRequiredService<MongoDbRunner>().ConnectionString;
|
||||||
|
|
||||||
|
// get client
|
||||||
BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer());
|
BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer());
|
||||||
return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database);
|
return new MongoClient(connectionString).GetDatabase(mongoConfig.Database);
|
||||||
});
|
});
|
||||||
services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
|
services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
|
||||||
services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
|
services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
|
||||||
|
@ -102,12 +117,18 @@ namespace StardewModdingAPI.Web
|
||||||
config
|
config
|
||||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
|
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
|
||||||
.UseSimpleAssemblyNameTypeSerializer()
|
.UseSimpleAssemblyNameTypeSerializer()
|
||||||
.UseRecommendedSerializerSettings()
|
.UseRecommendedSerializerSettings();
|
||||||
.UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
|
|
||||||
|
if (mongoConfig.IsConfigured())
|
||||||
|
{
|
||||||
|
config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
|
||||||
{
|
{
|
||||||
MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop),
|
MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop),
|
||||||
CheckConnection = false // error on startup takes down entire process
|
CheckConnection = false // error on startup takes down entire process
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
config.UseMemoryStorage();
|
||||||
});
|
});
|
||||||
|
|
||||||
// init API clients
|
// init API clients
|
||||||
|
@ -151,14 +172,18 @@ namespace StardewModdingAPI.Web
|
||||||
|
|
||||||
services.AddSingleton<IPastebinClient>(new PastebinClient(
|
services.AddSingleton<IPastebinClient>(new PastebinClient(
|
||||||
baseUrl: api.PastebinBaseUrl,
|
baseUrl: api.PastebinBaseUrl,
|
||||||
userAgent: userAgent,
|
userAgent: userAgent
|
||||||
userKey: api.PastebinUserKey,
|
|
||||||
devKey: api.PastebinDevKey
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// init helpers
|
// init helpers
|
||||||
services.AddSingleton<IGzipHelper>(new GzipHelper());
|
services
|
||||||
|
.AddSingleton<IGzipHelper>(new GzipHelper())
|
||||||
|
.AddSingleton<IStorageProvider>(serv => new StorageProvider(
|
||||||
|
serv.GetRequiredService<IOptions<ApiClientsConfig>>(),
|
||||||
|
serv.GetRequiredService<IPastebinClient>(),
|
||||||
|
serv.GetRequiredService<IGzipHelper>()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
|
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>
|
||||||
|
|
|
@ -15,6 +15,9 @@ namespace StardewModdingAPI.Web.ViewModels
|
||||||
/// <summary>A short sentence shown under the beta download button, if any.</summary>
|
/// <summary>A short sentence shown under the beta download button, if any.</summary>
|
||||||
public string BetaBlurb { get; set; }
|
public string BetaBlurb { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A list of supports to credit on the main page, in Markdown format.</summary>
|
||||||
|
public string SupporterList { get; set; }
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Public methods
|
** Public methods
|
||||||
|
@ -26,11 +29,13 @@ namespace StardewModdingAPI.Web.ViewModels
|
||||||
/// <param name="stableVersion">The latest stable SMAPI version.</param>
|
/// <param name="stableVersion">The latest stable SMAPI version.</param>
|
||||||
/// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param>
|
/// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param>
|
||||||
/// <param name="betaBlurb">A short sentence shown under the beta download button, if any.</param>
|
/// <param name="betaBlurb">A short sentence shown under the beta download button, if any.</param>
|
||||||
internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb)
|
/// <param name="supporterList">A list of supports to credit on the main page, in Markdown format.</param>
|
||||||
|
internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb, string supporterList)
|
||||||
{
|
{
|
||||||
this.StableVersion = stableVersion;
|
this.StableVersion = stableVersion;
|
||||||
this.BetaVersion = betaVersion;
|
this.BetaVersion = betaVersion;
|
||||||
this.BetaBlurb = betaBlurb;
|
this.BetaBlurb = betaBlurb;
|
||||||
|
this.SupporterList = supporterList;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
|
@ -24,7 +25,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
/// <summary>The schema validation errors, if any.</summary>
|
/// <summary>The schema validation errors, if any.</summary>
|
||||||
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
|
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
|
||||||
|
|
||||||
/// <summary>An error which occurred while uploading the JSON to Pastebin.</summary>
|
/// <summary>A non-blocking warning while uploading the file.</summary>
|
||||||
|
public string UploadWarning { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the uploaded file will no longer be available.</summary>
|
||||||
|
public DateTime? Expiry { get; set; }
|
||||||
|
|
||||||
|
/// <summary>An error which occurred while uploading the JSON.</summary>
|
||||||
public string UploadError { get; set; }
|
public string UploadError { get; set; }
|
||||||
|
|
||||||
/// <summary>An error which occurred while parsing the JSON.</summary>
|
/// <summary>An error which occurred while parsing the JSON.</summary>
|
||||||
|
@ -41,7 +48,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
public JsonValidatorModel() { }
|
public JsonValidatorModel() { }
|
||||||
|
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="pasteID">The paste ID.</param>
|
/// <param name="pasteID">The stored file ID.</param>
|
||||||
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
/// <param name="schemaName">The schema name with which the JSON was validated.</param>
|
||||||
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
|
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
|
||||||
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
|
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
|
||||||
|
@ -53,14 +60,18 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
|
|
||||||
/// <summary>Set the validated content.</summary>
|
/// <summary>Set the validated content.</summary>
|
||||||
/// <param name="content">The validated content.</param>
|
/// <param name="content">The validated content.</param>
|
||||||
public JsonValidatorModel SetContent(string content)
|
/// <param name="expiry">When the uploaded file will no longer be available.</param>
|
||||||
|
/// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
|
||||||
|
public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null)
|
||||||
{
|
{
|
||||||
this.Content = content;
|
this.Content = content;
|
||||||
|
this.Expiry = expiry;
|
||||||
|
this.UploadWarning = uploadWarning;
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Set the error which occurred while uploading the log to Pastebin.</summary>
|
/// <summary>Set the error which occurred while uploading the JSON.</summary>
|
||||||
/// <param name="error">The error message.</param>
|
/// <param name="error">The error message.</param>
|
||||||
public JsonValidatorModel SetUploadError(string error)
|
public JsonValidatorModel SetUploadError(string error)
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
@using Markdig
|
||||||
@using Microsoft.Extensions.Options
|
@using Microsoft.Extensions.Options
|
||||||
@using StardewModdingAPI.Web.Framework
|
@using StardewModdingAPI.Web.Framework
|
||||||
@using StardewModdingAPI.Web.Framework.ConfigModels
|
@using StardewModdingAPI.Web.Framework.ConfigModels
|
||||||
|
@ -104,19 +105,12 @@ else
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<p>
|
@if (!string.IsNullOrWhiteSpace(Model.SupporterList))
|
||||||
Special thanks to
|
{
|
||||||
<a href="https://www.nexusmods.com/users/65566526?tab=user+files">bwdy</a>,
|
@Html.Raw(Markdig.Markdown.ToHtml(
|
||||||
hawkfalcon,
|
$"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!"
|
||||||
<a href="https://twitter.com/iKeychain">iKeychain</a>,
|
))
|
||||||
jwdred,
|
}
|
||||||
<a href="https://www.nexusmods.com/users/12252523">Karmylla</a>,
|
|
||||||
<a href="https://www.nexusmods.com/stardewvalley/users/51777556">minervamaga</a>,
|
|
||||||
Pucklynn,
|
|
||||||
Renorien,
|
|
||||||
Robby LaFarge,
|
|
||||||
and a few anonymous users for their ongoing support on Patreon; you're awesome!
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 id="modcreators">For mod creators</h2>
|
<h2 id="modcreators">For mod creators</h2>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
|
@ -22,10 +22,10 @@
|
||||||
|
|
||||||
<h2>Data collected and transmitted</h2>
|
<h2>Data collected and transmitted</h2>
|
||||||
<h3 id="web-logging">Web logging</h3>
|
<h3 id="web-logging">Web logging</h3>
|
||||||
<p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p>
|
<p>This website and SMAPI's web API are hosted on Microsoft Azure. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web apps or its developers. For more information, see the <a href="https://azure.microsoft.com/en-ca/support/legal/">Microsoft Azure legal resources</a>.</p>
|
||||||
|
|
||||||
<h3>Update checks</h3>
|
<h3>Update checks</h3>
|
||||||
<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p>
|
<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends basic metadata like your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web app.</p>
|
||||||
|
|
||||||
<p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p>
|
<p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p>
|
||||||
<ol>
|
<ol>
|
||||||
|
@ -34,8 +34,8 @@
|
||||||
<li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li>
|
<li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h3>Log parser</h3>
|
<h3>Log parser and JSON validator</h3>
|
||||||
<p>The <a href="https://smapi.io/log">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p>
|
<p>The <a href="https://smapi.io/log">log parser</a> and <a href="https://smapi.io/json">JSON validator</a> let you upload files to analyze and share with other users. The log data is stored for 30 days in an obfuscated form in a private Microsoft Azure Blob storage account. No personal information is stored by the log parser beyond what you choose to upload as part of those files.</p>
|
||||||
|
|
||||||
<h3>Multiplayer sync</h3>
|
<h3>Multiplayer sync</h3>
|
||||||
<p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p>
|
<p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p>
|
||||||
|
|
|
@ -1,13 +1,15 @@
|
||||||
|
@using Humanizer
|
||||||
@using StardewModdingAPI.Web.Framework
|
@using StardewModdingAPI.Web.Framework
|
||||||
@using StardewModdingAPI.Web.ViewModels.JsonValidator
|
@using StardewModdingAPI.Web.ViewModels.JsonValidator
|
||||||
@model JsonValidatorModel
|
@model JsonValidatorModel
|
||||||
|
|
||||||
@{
|
@{
|
||||||
// get view data
|
// get view data
|
||||||
string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID });
|
string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID }, absoluteUrl: true);
|
||||||
string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName });
|
string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName });
|
||||||
string schemaDisplayName = null;
|
string schemaDisplayName = null;
|
||||||
bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName != "None";
|
bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none";
|
||||||
|
bool isEditView = Model.Content == null || Model.SchemaName?.ToLower() == "edit";
|
||||||
|
|
||||||
// build title
|
// build title
|
||||||
ViewData["Title"] = "JSON validator";
|
ViewData["Title"] = "JSON validator";
|
||||||
|
@ -26,17 +28,17 @@
|
||||||
{
|
{
|
||||||
<meta name="robots" content="noindex" />
|
<meta name="robots" content="noindex" />
|
||||||
}
|
}
|
||||||
<link rel="stylesheet" href="~/Content/css/json-validator.css" />
|
<link rel="stylesheet" href="~/Content/css/json-validator.css?r=20191204" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
|
||||||
<script src="~/Content/js/json-validator.js"></script>
|
<script src="~/Content/js/json-validator.js?r=20191204"></script>
|
||||||
<script>
|
<script>
|
||||||
$(function() {
|
$(function() {
|
||||||
smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", values: null)), @Json.Serialize(Model.PasteID));
|
smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
}
|
}
|
||||||
|
@ -59,7 +61,7 @@ else if (Model.ParseError != null)
|
||||||
<small v-pre>Error details: @Model.ParseError</small>
|
<small v-pre>Error details: @Model.ParseError</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else if (Model.PasteID != null)
|
else if (!isEditView && Model.PasteID != null)
|
||||||
{
|
{
|
||||||
<div class="banner success">
|
<div class="banner success">
|
||||||
<strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br />
|
<strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br />
|
||||||
|
@ -67,8 +69,20 @@ else if (Model.PasteID != null)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@* save warnings *@
|
||||||
|
@if (Model.UploadWarning != null || Model.Expiry != null)
|
||||||
|
{
|
||||||
|
<div class="save-metadata" v-pre>
|
||||||
|
@if (Model.Expiry != null)
|
||||||
|
{
|
||||||
|
<text>This JSON file will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text>
|
||||||
|
}
|
||||||
|
<!--@Model.UploadWarning-->
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@* upload new file *@
|
@* upload new file *@
|
||||||
@if (Model.Content == null)
|
@if (isEditView)
|
||||||
{
|
{
|
||||||
<h2>Upload a JSON file</h2>
|
<h2>Upload a JSON file</h2>
|
||||||
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
|
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
|
||||||
|
@ -84,7 +98,7 @@ else if (Model.PasteID != null)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Drag the file onto this textbox (or paste the text in):<br />
|
Drag the file onto this textbox (or paste the text in):<br />
|
||||||
<textarea id="input" name="Content" placeholder="paste file here"></textarea>
|
<textarea id="input" name="Content" placeholder="paste file here">@Model.Content</textarea>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Click this button:<br />
|
Click this button:<br />
|
||||||
|
@ -95,26 +109,23 @@ else if (Model.PasteID != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@* validation results *@
|
@* validation results *@
|
||||||
@if (Model.Content != null)
|
@if (!isEditView)
|
||||||
{
|
{
|
||||||
<div id="output">
|
<div id="output">
|
||||||
@if (Model.UploadError == null)
|
@if (Model.UploadError == null)
|
||||||
{
|
{
|
||||||
<div>
|
<h2>Validation</h2>
|
||||||
Change JSON format:
|
<p>
|
||||||
<select id="format" name="format">
|
@(Model.Errors.Any() ? "Oops, found some issues with your JSON." : "No errors found!")
|
||||||
@foreach (var pair in Model.SchemaFormats)
|
@if (!isValidSchema)
|
||||||
{
|
{
|
||||||
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
|
<text>(You have no schema selected, so only the basic JSON syntax was checked.)</text>
|
||||||
}
|
}
|
||||||
</select>
|
else if (Model.FormatUrl != null)
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Validation errors</h2>
|
|
||||||
@if (Model.FormatUrl != null)
|
|
||||||
{
|
{
|
||||||
<p>See <a href="@Model.FormatUrl">format documentation</a>.</p>
|
<text>See <a href="@Model.FormatUrl">format documentation</a> for more info.</text>
|
||||||
}
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
@if (Model.Errors.Any())
|
@if (Model.Errors.Any())
|
||||||
{
|
{
|
||||||
|
@ -135,13 +146,17 @@ else if (Model.PasteID != null)
|
||||||
}
|
}
|
||||||
</table>
|
</table>
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
<p>No errors found.</p>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
<h2>Content</h2>
|
<h2>Content</h2>
|
||||||
|
<div>
|
||||||
|
You can change JSON format (<select id="format" name="format">
|
||||||
|
@foreach (var pair in Model.SchemaFormats)
|
||||||
|
{
|
||||||
|
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
|
||||||
|
}
|
||||||
|
</select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = "edit" }))">edit this file</a>.
|
||||||
|
</div>
|
||||||
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
|
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
|
||||||
|
|
||||||
@if (isValidSchema)
|
@if (isValidSchema)
|
||||||
|
|
|
@ -13,6 +13,8 @@
|
||||||
.Cast<LogLevel>()
|
.Cast<LogLevel>()
|
||||||
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
|
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
|
||||||
JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
|
JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
|
||||||
|
|
||||||
|
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Head {
|
@section Head {
|
||||||
|
@ -50,7 +52,7 @@ else if (Model.ParseError != null)
|
||||||
{
|
{
|
||||||
<div class="banner error" v-pre>
|
<div class="banner error" v-pre>
|
||||||
<strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br />
|
<strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br />
|
||||||
Share this URL when asking for help: <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }))</code><br />
|
Share this URL when asking for help: <code>@curPageUrl</code><br />
|
||||||
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)<br />
|
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)<br />
|
||||||
<br />
|
<br />
|
||||||
<small v-pre>Error details: @Model.ParseError</small>
|
<small v-pre>Error details: @Model.ParseError</small>
|
||||||
|
@ -59,7 +61,7 @@ else if (Model.ParseError != null)
|
||||||
else if (Model.ParsedLog?.IsValid == true)
|
else if (Model.ParsedLog?.IsValid == true)
|
||||||
{
|
{
|
||||||
<div class="banner success" v-pre>
|
<div class="banner success" v-pre>
|
||||||
<strong>Share this link to let someone else see the log:</strong> <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })</code><br />
|
<strong>Share this link to let someone else see the log:</strong> <code>@curPageUrl</code><br />
|
||||||
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)
|
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@ -67,12 +69,16 @@ else if (Model.ParsedLog?.IsValid == true)
|
||||||
@* save warnings *@
|
@* save warnings *@
|
||||||
@if (Model.UploadWarning != null || Model.Expiry != null)
|
@if (Model.UploadWarning != null || Model.Expiry != null)
|
||||||
{
|
{
|
||||||
|
@if (Model.UploadWarning != null)
|
||||||
|
{
|
||||||
|
<text>⚠️ @Model.UploadWarning<br /></text>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="save-metadata" v-pre>
|
<div class="save-metadata" v-pre>
|
||||||
@if (Model.Expiry != null)
|
@if (Model.Expiry != null)
|
||||||
{
|
{
|
||||||
<text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()).</text>
|
<text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()).</text>
|
||||||
}
|
}
|
||||||
<!--@Model.UploadWarning-->
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -294,10 +300,7 @@ else if (Model.ParsedLog?.IsValid == true)
|
||||||
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
|
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
|
||||||
|
|
||||||
<tr class="mod @levelStr @sectionStartClass"
|
<tr class="mod @levelStr @sectionStartClass"
|
||||||
@if (message.IsStartOfSection)
|
@if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
|
||||||
{
|
|
||||||
<text>v-on:click="toggleSection('@message.Section')"</text>
|
|
||||||
}
|
|
||||||
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
|
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
|
||||||
<td v-pre>@message.Time</td>
|
<td v-pre>@message.Time</td>
|
||||||
<td v-pre>@message.Level.ToString().ToUpper()</td>
|
<td v-pre>@message.Level.ToString().ToUpper()</td>
|
||||||
|
@ -307,8 +310,12 @@ else if (Model.ParsedLog?.IsValid == true)
|
||||||
@if (message.IsStartOfSection)
|
@if (message.IsStartOfSection)
|
||||||
{
|
{
|
||||||
<span class="section-toggle-message">
|
<span class="section-toggle-message">
|
||||||
<template v-if="sectionsAllow('@message.Section')">This section is shown. Click here to hide it.</template>
|
<template v-if="sectionsAllow('@message.Section')">
|
||||||
<template v-else>This section is hidden. Click here to show it.</template>
|
This section is shown. Click here to hide it.
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
This section is hidden. Click here to show it.
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
|
|
|
@ -8,28 +8,21 @@
|
||||||
|
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
"Site": {
|
|
||||||
"BetaEnabled": false,
|
|
||||||
"BetaBlurb": null
|
|
||||||
},
|
|
||||||
|
|
||||||
"ApiClients": {
|
"ApiClients": {
|
||||||
"AmazonAccessKey": null,
|
"AzureBlobConnectionString": null,
|
||||||
"AmazonSecretKey": null,
|
|
||||||
|
|
||||||
"GitHubUsername": null,
|
"GitHubUsername": null,
|
||||||
"GitHubPassword": null,
|
"GitHubPassword": null,
|
||||||
|
|
||||||
"NexusApiKey": null,
|
"NexusApiKey": null
|
||||||
|
|
||||||
"PastebinUserKey": null,
|
|
||||||
"PastebinDevKey": null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"MongoDB": {
|
"MongoDB": {
|
||||||
"Host": "localhost",
|
"ConnectionString": null,
|
||||||
"Username": null,
|
|
||||||
"Password": null,
|
|
||||||
"Database": "smapi-edge"
|
"Database": "smapi-edge"
|
||||||
|
},
|
||||||
|
|
||||||
|
"BackgroundServices": {
|
||||||
|
"Enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,17 +16,17 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
"Site": {
|
"Site": {
|
||||||
"BetaEnabled": null,
|
"BetaEnabled": false,
|
||||||
"BetaBlurb": null
|
"BetaBlurb": null,
|
||||||
|
"SupporterList": null
|
||||||
},
|
},
|
||||||
|
|
||||||
"ApiClients": {
|
"ApiClients": {
|
||||||
"UserAgent": "SMAPI/{0} (+https://smapi.io)",
|
"UserAgent": "SMAPI/{0} (+https://smapi.io)",
|
||||||
|
|
||||||
"AmazonAccessKey": null,
|
"AzureBlobConnectionString": null,
|
||||||
"AmazonSecretKey": null,
|
"AzureBlobTempContainer": "smapi-web-temp",
|
||||||
"AmazonRegion": "us-east-1",
|
"AzureBlobTempExpiryDays": 30,
|
||||||
"AmazonLogBucket": "smapi-log-parser",
|
|
||||||
|
|
||||||
"ChucklefishBaseUrl": "https://community.playstarbound.com",
|
"ChucklefishBaseUrl": "https://community.playstarbound.com",
|
||||||
"ChucklefishModPageUrlFormat": "resources/{0}",
|
"ChucklefishModPageUrlFormat": "resources/{0}",
|
||||||
|
@ -46,16 +46,12 @@
|
||||||
"NexusModUrlFormat": "mods/{0}",
|
"NexusModUrlFormat": "mods/{0}",
|
||||||
"NexusModScrapeUrlFormat": "mods/{0}?tab=files",
|
"NexusModScrapeUrlFormat": "mods/{0}?tab=files",
|
||||||
|
|
||||||
"PastebinBaseUrl": "https://pastebin.com/",
|
"PastebinBaseUrl": "https://pastebin.com/"
|
||||||
"PastebinUserKey": null,
|
|
||||||
"PastebinDevKey": null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"MongoDB": {
|
"MongoDB": {
|
||||||
"Host": null,
|
"ConnectionString": null,
|
||||||
"Username": null,
|
"Database": "smapi"
|
||||||
"Password": null,
|
|
||||||
"Database": null
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"ModCompatibilityList": {
|
"ModCompatibilityList": {
|
||||||
|
|
|
@ -41,6 +41,12 @@
|
||||||
background: #FCC;
|
background: #FCC;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-metadata {
|
||||||
|
margin-top: 1em;
|
||||||
|
font-size: 0.8em;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Validation results
|
** Validation results
|
||||||
*********/
|
*********/
|
||||||
|
|
|
@ -70,10 +70,10 @@ smapi.LineNumberRange = function (maxLines) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* UI logic for the JSON validator page.
|
* UI logic for the JSON validator page.
|
||||||
* @param {any} sectionUrl The base JSON validator page URL.
|
* @param {string} urlFormat The URL format for a file, with $schemaName and $id placeholders.
|
||||||
* @param {any} pasteID The Pastebin paste ID for the content being viewed, if any.
|
* @param {string} fileId The file ID for the content being viewed, if any.
|
||||||
*/
|
*/
|
||||||
smapi.jsonValidator = function (sectionUrl, pasteID) {
|
smapi.jsonValidator = function (urlFormat, fileId) {
|
||||||
/**
|
/**
|
||||||
* The original content element.
|
* The original content element.
|
||||||
*/
|
*/
|
||||||
|
@ -138,7 +138,7 @@ smapi.jsonValidator = function (sectionUrl, pasteID) {
|
||||||
// change format
|
// change format
|
||||||
$("#output #format").on("change", function() {
|
$("#output #format").on("change", function() {
|
||||||
var schemaName = $(this).val();
|
var schemaName = $(this).val();
|
||||||
location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString();
|
location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", fileId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// upload form
|
// upload form
|
||||||
|
|
|
@ -11,9 +11,9 @@
|
||||||
"title": "Format version",
|
"title": "Format version",
|
||||||
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
|
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": "1.9",
|
"const": "1.11.0",
|
||||||
"@errorMessages": {
|
"@errorMessages": {
|
||||||
"const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.9'."
|
"const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.11.0'."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConfigSchema": {
|
"ConfigSchema": {
|
||||||
|
@ -51,8 +51,7 @@
|
||||||
"if": {
|
"if": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"AllowBlank": { "const": false }
|
"AllowBlank": { "const": false }
|
||||||
},
|
}
|
||||||
"required": [ "AllowBlank" ]
|
|
||||||
},
|
},
|
||||||
"then": {
|
"then": {
|
||||||
"required": [ "Default" ]
|
"required": [ "Default" ]
|
||||||
|
@ -194,6 +193,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"MoveEntries": {
|
"MoveEntries": {
|
||||||
|
"title": "Move entries",
|
||||||
|
"description": "Change the entry order in a list asset like Data/MoviesReactions. (Using this with a non-list asset will cause an error, since those have no order.)",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -259,6 +260,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"MapProperties": {
|
||||||
|
"title": "Map properties",
|
||||||
|
"description": "The map properties (not tile properties) to add, replace, or delete. To add an property, just specify a key that doesn't exist; to delete an entry, set the value to null (like \"some key\": null). This field supports tokens in property keys and values.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
"When": {
|
"When": {
|
||||||
"title": "When",
|
"title": "When",
|
||||||
"description": "Only apply the patch if the given conditions match.",
|
"description": "Only apply the patch if the given conditions match.",
|
||||||
|
@ -266,6 +275,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allOf": [
|
"allOf": [
|
||||||
|
{
|
||||||
|
"required": [ "Action" ]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"if": {
|
"if": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -300,7 +312,7 @@
|
||||||
},
|
},
|
||||||
"then": {
|
"then": {
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "Fields", "Entries", "MoveEntries" ]
|
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "Fields", "Entries", "MoveEntries" ]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -313,7 +325,7 @@
|
||||||
"then": {
|
"then": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"FromFile": {
|
"FromFile": {
|
||||||
"description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you if it's a .tbin file:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder."
|
"description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder."
|
||||||
},
|
},
|
||||||
"FromArea": {
|
"FromArea": {
|
||||||
"description": "The part of the source map to copy. Defaults to the whole source map."
|
"description": "The part of the source map to copy. Defaults to the whole source map."
|
||||||
|
@ -323,9 +335,8 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"propertyNames": {
|
"propertyNames": {
|
||||||
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ]
|
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties" ]
|
||||||
},
|
}
|
||||||
"required": [ "FromFile", "ToArea" ]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
@ -355,26 +366,26 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"X": {
|
"X": {
|
||||||
"title": "X-Coordinate",
|
"title": "X-Coordinate",
|
||||||
"description": "Location in pixels of the top-left of the rectangle",
|
"description": "The X position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.",
|
||||||
"type": "integer",
|
"type": [ "integer", "string" ],
|
||||||
"minimum:": 0
|
"minimum:": 0
|
||||||
},
|
},
|
||||||
"Y": {
|
"Y": {
|
||||||
"title": "Y-Coordinate",
|
"title": "Y-Coordinate",
|
||||||
"description": "Location in pixels of the top-left of the rectangle",
|
"description": "The Y position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.",
|
||||||
"type": "integer",
|
"type": [ "integer", "string" ],
|
||||||
"minimum:": 0
|
"minimum:": 0
|
||||||
},
|
},
|
||||||
"Width": {
|
"Width": {
|
||||||
"title": "Width",
|
"title": "Width",
|
||||||
"description": "The width of the rectangle",
|
"description": "The width of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
|
||||||
"type": "integer",
|
"type": [ "integer", "string" ],
|
||||||
"minimum:": 0
|
"minimum:": 0
|
||||||
},
|
},
|
||||||
"Height": {
|
"Height": {
|
||||||
"title": "Height",
|
"title": "Height",
|
||||||
"description": "The height of the rectangle",
|
"description": "The height of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
|
||||||
"type": "integer",
|
"type": [ "integer", "string" ],
|
||||||
"minimum:": 0
|
"minimum:": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -20,7 +20,7 @@ namespace StardewModdingAPI
|
||||||
** Public
|
** Public
|
||||||
****/
|
****/
|
||||||
/// <summary>SMAPI's current semantic version.</summary>
|
/// <summary>SMAPI's current semantic version.</summary>
|
||||||
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.1");
|
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0");
|
||||||
|
|
||||||
/// <summary>The minimum supported version of Stardew Valley.</summary>
|
/// <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.0");
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using StardewValley;
|
||||||
|
using StardewValley.Objects;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Events
|
||||||
|
{
|
||||||
|
/// <summary>Event arguments for a <see cref="IWorldEvents.ChestInventoryChanged"/> event.</summary>
|
||||||
|
public class ChestInventoryChangedEventArgs : EventArgs
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>The chest whose inventory changed.</summary>
|
||||||
|
public Chest Chest { get; }
|
||||||
|
|
||||||
|
/// <summary>The location containing the chest.</summary>
|
||||||
|
public GameLocation Location { get; }
|
||||||
|
|
||||||
|
/// <summary>The added item stacks.</summary>
|
||||||
|
public IEnumerable<Item> Added { get; }
|
||||||
|
|
||||||
|
/// <summary>The removed item stacks.</summary>
|
||||||
|
public IEnumerable<Item> Removed { get; }
|
||||||
|
|
||||||
|
/// <summary>The item stacks whose size changed.</summary>
|
||||||
|
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="chest">The chest whose inventory changed.</param>
|
||||||
|
/// <param name="location">The location containing the chest.</param>
|
||||||
|
/// <param name="added">The added item stacks.</param>
|
||||||
|
/// <param name="removed">The removed item stacks.</param>
|
||||||
|
/// <param name="quantityChanged">The item stacks whose size changed.</param>
|
||||||
|
internal ChestInventoryChangedEventArgs(Chest chest, GameLocation location, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
|
||||||
|
{
|
||||||
|
this.Location = location;
|
||||||
|
this.Chest = chest;
|
||||||
|
this.Added = added;
|
||||||
|
this.Removed = removed;
|
||||||
|
this.QuantityChanged = quantityChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,6 +23,9 @@ namespace StardewModdingAPI.Events
|
||||||
/// <summary>Raised after objects are added or removed in a location.</summary>
|
/// <summary>Raised after objects are added or removed in a location.</summary>
|
||||||
event EventHandler<ObjectListChangedEventArgs> ObjectListChanged;
|
event EventHandler<ObjectListChangedEventArgs> ObjectListChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised after items are added or removed from a chest.</summary>
|
||||||
|
event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged;
|
||||||
|
|
||||||
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
||||||
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
|
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Events
|
namespace StardewModdingAPI.Events
|
||||||
|
@ -14,13 +13,13 @@ namespace StardewModdingAPI.Events
|
||||||
/// <summary>The player whose inventory changed.</summary>
|
/// <summary>The player whose inventory changed.</summary>
|
||||||
public Farmer Player { get; }
|
public Farmer Player { get; }
|
||||||
|
|
||||||
/// <summary>The added items.</summary>
|
/// <summary>The added item stacks.</summary>
|
||||||
public IEnumerable<Item> Added { get; }
|
public IEnumerable<Item> Added { get; }
|
||||||
|
|
||||||
/// <summary>The removed items.</summary>
|
/// <summary>The removed item stacks.</summary>
|
||||||
public IEnumerable<Item> Removed { get; }
|
public IEnumerable<Item> Removed { get; }
|
||||||
|
|
||||||
/// <summary>The items whose stack sizes changed, with the relative change.</summary>
|
/// <summary>The item stacks whose size changed.</summary>
|
||||||
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
|
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
|
||||||
|
|
||||||
/// <summary>Whether the affected player is the local one.</summary>
|
/// <summary>Whether the affected player is the local one.</summary>
|
||||||
|
@ -32,28 +31,15 @@ namespace StardewModdingAPI.Events
|
||||||
*********/
|
*********/
|
||||||
/// <summary>Construct an instance.</summary>
|
/// <summary>Construct an instance.</summary>
|
||||||
/// <param name="player">The player whose inventory changed.</param>
|
/// <param name="player">The player whose inventory changed.</param>
|
||||||
/// <param name="changedItems">The inventory changes.</param>
|
/// <param name="added">The added item stacks.</param>
|
||||||
internal InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems)
|
/// <param name="removed">The removed item stacks.</param>
|
||||||
|
/// <param name="quantityChanged">The item stacks whose size changed.</param>
|
||||||
|
internal InventoryChangedEventArgs(Farmer player, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
|
||||||
{
|
{
|
||||||
this.Player = player;
|
this.Player = player;
|
||||||
this.Added = changedItems
|
this.Added = added;
|
||||||
.Where(n => n.ChangeType == ChangeType.Added)
|
this.Removed = removed;
|
||||||
.Select(p => p.Item)
|
this.QuantityChanged = quantityChanged;
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
this.Removed = changedItems
|
|
||||||
.Where(n => n.ChangeType == ChangeType.Removed)
|
|
||||||
.Select(p => p.Item)
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
this.QuantityChanged = changedItems
|
|
||||||
.Where(n => n.ChangeType == ChangeType.StackChange)
|
|
||||||
.Select(change => new ItemStackSizeChange(
|
|
||||||
item: change.Item,
|
|
||||||
oldSize: change.Item.Stack - change.StackChange,
|
|
||||||
newSize: change.Item.Stack
|
|
||||||
))
|
|
||||||
.ToArray();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +0,0 @@
|
||||||
using StardewValley;
|
|
||||||
|
|
||||||
namespace StardewModdingAPI.Events
|
|
||||||
{
|
|
||||||
/// <summary>Represents an inventory slot that changed.</summary>
|
|
||||||
public class ItemStackChange
|
|
||||||
{
|
|
||||||
/*********
|
|
||||||
** Accessors
|
|
||||||
*********/
|
|
||||||
/// <summary>The item in the slot.</summary>
|
|
||||||
public Item Item { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The amount by which the item's stack size changed.</summary>
|
|
||||||
public int StackChange { get; set; }
|
|
||||||
|
|
||||||
/// <summary>How the inventory slot changed.</summary>
|
|
||||||
public ChangeType ChangeType { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -42,8 +42,8 @@ namespace StardewModdingAPI.Framework.Content
|
||||||
Texture2D target = this.Data;
|
Texture2D target = this.Data;
|
||||||
|
|
||||||
// get areas
|
// get areas
|
||||||
sourceArea = sourceArea ?? new Rectangle(0, 0, source.Width, source.Height);
|
sourceArea ??= new Rectangle(0, 0, source.Width, source.Height);
|
||||||
targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
|
targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
|
||||||
|
|
||||||
// validate
|
// validate
|
||||||
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
|
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Framework.Content
|
||||||
|
{
|
||||||
|
/// <summary>A wrapper for <see cref="IAssetEditor"/> and <see cref="IAssetLoader"/> for internal cache invalidation.</summary>
|
||||||
|
internal class AssetInterceptorChange
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>The mod which registered the interceptor.</summary>
|
||||||
|
public IModMetadata Mod { get; }
|
||||||
|
|
||||||
|
/// <summary>The interceptor instance.</summary>
|
||||||
|
public object Instance { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether the asset interceptor was added since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
|
||||||
|
public bool WasAdded { get; }
|
||||||
|
|
||||||
|
/// <summary>Whether the asset interceptor was removed since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
|
||||||
|
public bool WasRemoved => this.WasAdded;
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="mod">The mod registering the interceptor.</param>
|
||||||
|
/// <param name="instance">The interceptor. This must be an <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> instance.</param>
|
||||||
|
/// <param name="wasAdded">Whether the asset interceptor was added since the last tick; else removed.</param>
|
||||||
|
public AssetInterceptorChange(IModMetadata mod, object instance, bool wasAdded)
|
||||||
|
{
|
||||||
|
this.Mod = mod ?? throw new ArgumentNullException(nameof(mod));
|
||||||
|
this.Instance = instance ?? throw new ArgumentNullException(nameof(instance));
|
||||||
|
this.WasAdded = wasAdded;
|
||||||
|
|
||||||
|
if (!(instance is IAssetEditor) && !(instance is IAssetLoader))
|
||||||
|
throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get whether this instance can intercept the given asset.</summary>
|
||||||
|
/// <param name="asset">Basic metadata about the asset being loaded.</param>
|
||||||
|
public bool CanIntercept(IAssetInfo asset)
|
||||||
|
{
|
||||||
|
MethodInfo canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
if (canIntercept == null)
|
||||||
|
throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation.");
|
||||||
|
|
||||||
|
return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Private methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Get whether this instance can intercept the given asset.</summary>
|
||||||
|
/// <typeparam name="TAsset">The asset type.</typeparam>
|
||||||
|
/// <param name="asset">Basic metadata about the asset being loaded.</param>
|
||||||
|
private bool CanInterceptImpl<TAsset>(IAssetInfo asset)
|
||||||
|
{
|
||||||
|
// check edit
|
||||||
|
if (this.Instance is IAssetEditor editor)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (editor.CanEdit<TAsset>(asset))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// check load
|
||||||
|
if (this.Instance is IAssetLoader loader)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (loader.CanLoad<TAsset>(asset))
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -119,13 +119,12 @@ namespace StardewModdingAPI.Framework.Content
|
||||||
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
||||||
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
||||||
/// <returns>Returns the removed keys (if any).</returns>
|
/// <returns>Returns the removed keys (if any).</returns>
|
||||||
public IEnumerable<string> Remove(Func<string, Type, bool> predicate, bool dispose = false)
|
public IEnumerable<string> Remove(Func<string, object, bool> predicate, bool dispose)
|
||||||
{
|
{
|
||||||
List<string> removed = new List<string>();
|
List<string> removed = new List<string>();
|
||||||
foreach (string key in this.Cache.Keys.ToArray())
|
foreach (string key in this.Cache.Keys.ToArray())
|
||||||
{
|
{
|
||||||
Type type = this.Cache[key].GetType();
|
if (predicate(key, this.Cache[key]))
|
||||||
if (predicate(key, type))
|
|
||||||
{
|
{
|
||||||
this.Remove(key, dispose);
|
this.Remove(key, dispose);
|
||||||
removed.Add(key);
|
removed.Add(key);
|
||||||
|
|
|
@ -3,11 +3,11 @@ using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
|
||||||
using Microsoft.Xna.Framework.Content;
|
using Microsoft.Xna.Framework.Content;
|
||||||
using StardewModdingAPI.Framework.Content;
|
using StardewModdingAPI.Framework.Content;
|
||||||
using StardewModdingAPI.Framework.ContentManagers;
|
using StardewModdingAPI.Framework.ContentManagers;
|
||||||
using StardewModdingAPI.Framework.Reflection;
|
using StardewModdingAPI.Framework.Reflection;
|
||||||
|
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||||
using StardewModdingAPI.Metadata;
|
using StardewModdingAPI.Metadata;
|
||||||
using StardewModdingAPI.Toolkit.Serialization;
|
using StardewModdingAPI.Toolkit.Serialization;
|
||||||
using StardewModdingAPI.Toolkit.Utilities;
|
using StardewModdingAPI.Toolkit.Utilities;
|
||||||
|
@ -188,59 +188,6 @@ namespace StardewModdingAPI.Framework
|
||||||
return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
|
return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Purge assets from the cache that match one of the interceptors.</summary>
|
|
||||||
/// <param name="editors">The asset editors for which to purge matching assets.</param>
|
|
||||||
/// <param name="loaders">The asset loaders for which to purge matching assets.</param>
|
|
||||||
/// <returns>Returns the invalidated asset names.</returns>
|
|
||||||
public IEnumerable<string> InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
|
|
||||||
{
|
|
||||||
if (!editors.Any() && !loaders.Any())
|
|
||||||
return new string[0];
|
|
||||||
|
|
||||||
// get CanEdit/Load methods
|
|
||||||
MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
|
|
||||||
MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
|
|
||||||
if (canEdit == null || canLoad == null)
|
|
||||||
throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
|
|
||||||
|
|
||||||
// invalidate matching keys
|
|
||||||
return this.InvalidateCache(asset =>
|
|
||||||
{
|
|
||||||
// check loaders
|
|
||||||
MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
|
|
||||||
foreach (IAssetLoader loader in loaders)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if ((bool)canLoadGeneric.Invoke(loader, new object[] { asset }))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.GetModFor(loader).LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check editors
|
|
||||||
MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
|
|
||||||
foreach (IAssetEditor editor in editors)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if ((bool)canEditGeneric.Invoke(editor, new object[] { asset }))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
this.GetModFor(editor).LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// asset not affected by a loader or editor
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Purge matched assets from the cache.</summary>
|
/// <summary>Purge matched assets from the cache.</summary>
|
||||||
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
||||||
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
||||||
|
@ -261,24 +208,28 @@ namespace StardewModdingAPI.Framework
|
||||||
/// <returns>Returns the invalidated asset names.</returns>
|
/// <returns>Returns the invalidated asset names.</returns>
|
||||||
public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
|
public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
|
||||||
{
|
{
|
||||||
// invalidate cache
|
// invalidate cache & track removed assets
|
||||||
IDictionary<string, Type> removedAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
|
IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
|
||||||
foreach (IContentManager contentManager in this.ContentManagers)
|
foreach (IContentManager contentManager in this.ContentManagers)
|
||||||
{
|
{
|
||||||
foreach (Tuple<string, Type> asset in contentManager.InvalidateCache(predicate, dispose))
|
foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
|
||||||
removedAssetNames[asset.Item1] = asset.Item2;
|
{
|
||||||
|
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
|
// reload core game assets
|
||||||
int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager
|
if (removedAssets.Any())
|
||||||
|
{
|
||||||
// report result
|
IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager
|
||||||
if (removedAssetNames.Any())
|
this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace);
|
||||||
this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
|
}
|
||||||
else
|
else
|
||||||
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
|
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
|
||||||
|
|
||||||
return removedAssetNames.Keys;
|
return removedAssets.Keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dispose held resources.</summary>
|
/// <summary>Dispose held resources.</summary>
|
||||||
|
@ -308,33 +259,5 @@ namespace StardewModdingAPI.Framework
|
||||||
|
|
||||||
this.ContentManagers.Remove(contentManager);
|
this.ContentManagers.Remove(contentManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get the mod which registered an asset loader.</summary>
|
|
||||||
/// <param name="loader">The asset loader.</param>
|
|
||||||
/// <exception cref="KeyNotFoundException">The given loader couldn't be matched to a mod.</exception>
|
|
||||||
private IModMetadata GetModFor(IAssetLoader loader)
|
|
||||||
{
|
|
||||||
foreach (var pair in this.Loaders)
|
|
||||||
{
|
|
||||||
if (pair.Value.Contains(loader))
|
|
||||||
return pair.Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new KeyNotFoundException("This loader isn't associated with a known mod.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Get the mod which registered an asset editor.</summary>
|
|
||||||
/// <param name="editor">The asset editor.</param>
|
|
||||||
/// <exception cref="KeyNotFoundException">The given editor couldn't be matched to a mod.</exception>
|
|
||||||
private IModMetadata GetModFor(IAssetEditor editor)
|
|
||||||
{
|
|
||||||
foreach (var pair in this.Editors)
|
|
||||||
{
|
|
||||||
if (pair.Value.Contains(editor))
|
|
||||||
return pair.Key;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new KeyNotFoundException("This editor isn't associated with a known mod.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,6 +41,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
/// <summary>A list of disposable assets.</summary>
|
/// <summary>A list of disposable assets.</summary>
|
||||||
private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
|
private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
|
||||||
|
|
||||||
|
/// <summary>The disposable assets tracked by the base content manager.</summary>
|
||||||
|
/// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks>
|
||||||
|
private readonly List<IDisposable> BaseDisposableReferences;
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Accessors
|
** Accessors
|
||||||
|
@ -84,6 +88,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
|
|
||||||
// get asset data
|
// get asset data
|
||||||
this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
|
this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
|
||||||
|
this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Load an asset that has been processed by the content pipeline.</summary>
|
/// <summary>Load an asset that has been processed by the content pipeline.</summary>
|
||||||
|
@ -184,25 +189,25 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
/// <summary>Purge matched assets from the cache.</summary>
|
/// <summary>Purge matched assets from the cache.</summary>
|
||||||
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
||||||
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
||||||
/// <returns>Returns the invalidated asset names and types.</returns>
|
/// <returns>Returns the invalidated asset names and instances.</returns>
|
||||||
public IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
|
public IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
|
||||||
{
|
{
|
||||||
Dictionary<string, Type> removeAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
|
IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
|
||||||
this.Cache.Remove((key, type) =>
|
this.Cache.Remove((key, asset) =>
|
||||||
{
|
{
|
||||||
this.ParseCacheKey(key, out string assetName, out _);
|
this.ParseCacheKey(key, out string assetName, out _);
|
||||||
|
|
||||||
if (removeAssetNames.ContainsKey(assetName))
|
if (removeAssets.ContainsKey(assetName))
|
||||||
return true;
|
return true;
|
||||||
if (predicate(assetName, type))
|
if (predicate(assetName, asset.GetType()))
|
||||||
{
|
{
|
||||||
removeAssetNames[assetName] = type;
|
removeAssets[assetName] = asset;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
}, dispose);
|
||||||
|
|
||||||
return removeAssetNames.Select(p => Tuple.Create(p.Key, p.Value));
|
return removeAssets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dispose held resources.</summary>
|
/// <summary>Dispose held resources.</summary>
|
||||||
|
@ -258,22 +263,29 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
: base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
|
: base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Inject an asset into the cache.</summary>
|
/// <summary>Add tracking data to an asset and add it to the cache.</summary>
|
||||||
/// <typeparam name="T">The type of asset to inject.</typeparam>
|
/// <typeparam name="T">The type of asset to inject.</typeparam>
|
||||||
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
|
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
|
||||||
/// <param name="value">The asset value.</param>
|
/// <param name="value">The asset value.</param>
|
||||||
/// <param name="language">The language code for which to inject the asset.</param>
|
/// <param name="language">The language code for which to inject the asset.</param>
|
||||||
protected virtual void Inject<T>(string assetName, T value, LanguageCode language)
|
/// <param name="useCache">Whether to save the asset to the asset cache.</param>
|
||||||
|
protected virtual void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
|
||||||
{
|
{
|
||||||
// track asset key
|
// track asset key
|
||||||
if (value is Texture2D texture)
|
if (value is Texture2D texture)
|
||||||
texture.Name = assetName;
|
texture.Name = assetName;
|
||||||
|
|
||||||
// cache asset
|
// cache asset
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
assetName = this.AssertAndNormalizeAssetName(assetName);
|
assetName = this.AssertAndNormalizeAssetName(assetName);
|
||||||
this.Cache[assetName] = value;
|
this.Cache[assetName] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// avoid hard disposable references; see remarks on the field
|
||||||
|
this.BaseDisposableReferences.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Parse a cache key into its component parts.</summary>
|
/// <summary>Parse a cache key into its component parts.</summary>
|
||||||
/// <param name="cacheKey">The input cache key.</param>
|
/// <param name="cacheKey">The input cache key.</param>
|
||||||
/// <param name="assetName">The original asset name.</param>
|
/// <param name="assetName">The original asset name.</param>
|
||||||
|
|
|
@ -83,8 +83,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
|
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
|
||||||
{
|
{
|
||||||
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
|
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
|
||||||
if (useCache)
|
this.TrackAsset(assetName, managedAsset, language, useCache);
|
||||||
this.Inject(assetName, managedAsset, language);
|
|
||||||
return managedAsset;
|
return managedAsset;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,7 +110,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
}
|
}
|
||||||
|
|
||||||
// update cache & return data
|
// update cache & return data
|
||||||
this.Inject(assetName, data, language);
|
this.TrackAsset(assetName, data, language, useCache);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -131,7 +130,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
removeAssetNames.Contains(key)
|
removeAssetNames.Contains(key)
|
||||||
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
|
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
|
||||||
)
|
)
|
||||||
.Select(p => p.Item1)
|
.Select(p => p.Key)
|
||||||
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
|
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (invalidated.Any())
|
if (invalidated.Any())
|
||||||
|
@ -169,18 +168,19 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Inject an asset into the cache.</summary>
|
/// <summary>Add tracking data to an asset and add it to the cache.</summary>
|
||||||
/// <typeparam name="T">The type of asset to inject.</typeparam>
|
/// <typeparam name="T">The type of asset to inject.</typeparam>
|
||||||
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
|
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
|
||||||
/// <param name="value">The asset value.</param>
|
/// <param name="value">The asset value.</param>
|
||||||
/// <param name="language">The language code for which to inject the asset.</param>
|
/// <param name="language">The language code for which to inject the asset.</param>
|
||||||
protected override void Inject<T>(string assetName, T value, LanguageCode language)
|
/// <param name="useCache">Whether to save the asset to the asset cache.</param>
|
||||||
|
protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
|
||||||
{
|
{
|
||||||
// handle explicit language in asset name
|
// handle explicit language in asset name
|
||||||
{
|
{
|
||||||
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
|
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
|
||||||
{
|
{
|
||||||
this.Inject(newAssetName, value, newLanguage);
|
this.TrackAsset(newAssetName, value, newLanguage, useCache);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -192,10 +192,12 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
// only caches by the most specific key).
|
// only caches by the most specific key).
|
||||||
// 2. Because a mod asset loader/editor may have changed the asset in a way that
|
// 2. Because a mod asset loader/editor may have changed the asset in a way that
|
||||||
// doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
|
// doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
|
||||||
|
if (useCache)
|
||||||
|
{
|
||||||
string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
|
string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
|
||||||
base.Inject(assetName, value, language);
|
base.TrackAsset(assetName, value, language, useCache: true);
|
||||||
if (this.Cache.ContainsKey(keyWithLocale))
|
if (this.Cache.ContainsKey(keyWithLocale))
|
||||||
base.Inject(keyWithLocale, value, language);
|
base.TrackAsset(keyWithLocale, value, language, useCache: true);
|
||||||
|
|
||||||
// track whether the injected asset is translatable for is-loaded lookups
|
// track whether the injected asset is translatable for is-loaded lookups
|
||||||
if (this.Cache.ContainsKey(keyWithLocale))
|
if (this.Cache.ContainsKey(keyWithLocale))
|
||||||
|
@ -211,6 +213,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
else
|
else
|
||||||
this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
|
this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Load an asset file directly from the underlying content manager.</summary>
|
/// <summary>Load an asset file directly from the underlying content manager.</summary>
|
||||||
/// <typeparam name="T">The type of asset to load.</typeparam>
|
/// <typeparam name="T">The type of asset to load.</typeparam>
|
||||||
|
|
|
@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
/// <summary>Purge matched assets from the cache.</summary>
|
/// <summary>Purge matched assets from the cache.</summary>
|
||||||
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
/// <param name="predicate">Matches the asset keys to invalidate.</param>
|
||||||
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
|
||||||
/// <returns>Returns the invalidated asset names and types.</returns>
|
/// <returns>Returns the invalidated asset names and instances.</returns>
|
||||||
IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
|
IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,6 +105,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
|
|
||||||
// get local asset
|
// get local asset
|
||||||
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
|
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
|
||||||
|
T asset;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// get file
|
// get file
|
||||||
|
@ -118,22 +119,22 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
// XNB file
|
// XNB file
|
||||||
case ".xnb":
|
case ".xnb":
|
||||||
{
|
{
|
||||||
T data = this.RawLoad<T>(assetName, useCache: false);
|
asset = this.RawLoad<T>(assetName, useCache: false);
|
||||||
if (data is Map map)
|
if (asset is Map map)
|
||||||
{
|
{
|
||||||
this.NormalizeTilesheetPaths(map);
|
this.NormalizeTilesheetPaths(map);
|
||||||
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
|
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// unpacked data
|
// unpacked data
|
||||||
case ".json":
|
case ".json":
|
||||||
{
|
{
|
||||||
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data))
|
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out asset))
|
||||||
throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
|
throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
|
||||||
return data;
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// unpacked image
|
// unpacked image
|
||||||
case ".png":
|
case ".png":
|
||||||
|
@ -143,13 +144,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
|
throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
|
||||||
|
|
||||||
// fetch & cache
|
// fetch & cache
|
||||||
using (FileStream stream = File.OpenRead(file.FullName))
|
using FileStream stream = File.OpenRead(file.FullName);
|
||||||
{
|
|
||||||
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
|
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
|
||||||
texture = this.PremultiplyTransparency(texture);
|
texture = this.PremultiplyTransparency(texture);
|
||||||
return (T)(object)texture;
|
asset = (T)(object)texture;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
// unpacked map
|
// unpacked map
|
||||||
case ".tbin":
|
case ".tbin":
|
||||||
|
@ -163,8 +164,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
Map map = formatManager.LoadMap(file.FullName);
|
Map map = formatManager.LoadMap(file.FullName);
|
||||||
this.NormalizeTilesheetPaths(map);
|
this.NormalizeTilesheetPaths(map);
|
||||||
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
|
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
|
||||||
return (T)(object)map;
|
asset = (T)(object)map;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'.");
|
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'.");
|
||||||
|
@ -176,6 +178,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
|
||||||
throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
|
throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
|
||||||
throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
|
throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// track & return asset
|
||||||
|
this.TrackAsset(assetName, asset, language, useCache);
|
||||||
|
return asset;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Create a new content manager for temporary use.</summary>
|
/// <summary>Create a new content manager for temporary use.</summary>
|
||||||
|
|
|
@ -148,6 +148,9 @@ namespace StardewModdingAPI.Framework.Events
|
||||||
/// <summary>Raised after objects are added or removed in a location.</summary>
|
/// <summary>Raised after objects are added or removed in a location.</summary>
|
||||||
public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged;
|
public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged;
|
||||||
|
|
||||||
|
/// <summary>Raised after items are added or removed from a chest.</summary>
|
||||||
|
public readonly ManagedEvent<ChestInventoryChangedEventArgs> ChestInventoryChanged;
|
||||||
|
|
||||||
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
||||||
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
|
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
|
||||||
|
|
||||||
|
@ -221,6 +224,7 @@ namespace StardewModdingAPI.Framework.Events
|
||||||
this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
|
this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
|
||||||
this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
|
this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
|
||||||
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
|
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
|
||||||
|
this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged));
|
||||||
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
|
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
|
||||||
|
|
||||||
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
|
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
|
||||||
|
|
|
@ -51,6 +51,13 @@ namespace StardewModdingAPI.Framework.Events
|
||||||
remove => this.EventManager.ObjectListChanged.Remove(value);
|
remove => this.EventManager.ObjectListChanged.Remove(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Raised after items are added or removed from a chest.</summary>
|
||||||
|
public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged
|
||||||
|
{
|
||||||
|
add => this.EventManager.ChestInventoryChanged.Add(value);
|
||||||
|
remove => this.EventManager.ChestInventoryChanged.Remove(value);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
|
||||||
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
|
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
|
||||||
{
|
{
|
||||||
|
|
|
@ -105,6 +105,10 @@ namespace StardewModdingAPI.Framework
|
||||||
/// <param name="validOnly">Only return valid update keys.</param>
|
/// <param name="validOnly">Only return valid update keys.</param>
|
||||||
IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true);
|
IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true);
|
||||||
|
|
||||||
|
/// <summary>Get the mod IDs that must be installed to load this mod.</summary>
|
||||||
|
/// <param name="includeOptional">Whether to include optional dependencies.</param>
|
||||||
|
IEnumerable<string> GetRequiredModIds(bool includeOptional = false);
|
||||||
|
|
||||||
/// <summary>Whether the mod has at least one valid update key set.</summary>
|
/// <summary>Whether the mod has at least one valid update key set.</summary>
|
||||||
bool HasValidUpdateKeys();
|
bool HasValidUpdateKeys();
|
||||||
|
|
||||||
|
|
|
@ -129,6 +129,9 @@ namespace StardewModdingAPI.Framework.Input
|
||||||
[Obsolete("This method should only be called by the game itself.")]
|
[Obsolete("This method should only be called by the game itself.")]
|
||||||
public override GamePadState GetGamePadState()
|
public override GamePadState GetGamePadState()
|
||||||
{
|
{
|
||||||
|
if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff)
|
||||||
|
return base.GetGamePadState();
|
||||||
|
|
||||||
return this.ShouldSuppressNow()
|
return this.ShouldSuppressNow()
|
||||||
? this.SuppressedController
|
? this.SuppressedController
|
||||||
: this.RealController;
|
: this.RealController;
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
using StardewModdingAPI.Enums;
|
||||||
using StardewModdingAPI.Toolkit.Serialization;
|
using StardewModdingAPI.Toolkit.Serialization;
|
||||||
using StardewModdingAPI.Toolkit.Utilities;
|
using StardewModdingAPI.Toolkit.Utilities;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
|
@ -77,33 +79,45 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
|
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
|
||||||
public TModel ReadSaveData<TModel>(string key) where TModel : class
|
public TModel ReadSaveData<TModel>(string key) where TModel : class
|
||||||
{
|
{
|
||||||
if (!Game1.hasLoadedGame)
|
if (Context.LoadStage == LoadStage.None)
|
||||||
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
|
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
|
||||||
if (!Game1.IsMasterGame)
|
if (!Game1.IsMasterGame)
|
||||||
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
|
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
|
||||||
|
|
||||||
return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value)
|
|
||||||
? this.JsonHelper.Deserialize<TModel>(value)
|
string internalKey = this.GetSaveFileKey(key);
|
||||||
: null;
|
foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
|
||||||
|
{
|
||||||
|
if (dataField.TryGetValue(internalKey, out string value))
|
||||||
|
return this.JsonHelper.Deserialize<TModel>(value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary>
|
/// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary>
|
||||||
/// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam>
|
/// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam>
|
||||||
/// <param name="key">The unique key identifying the data.</param>
|
/// <param name="key">The unique key identifying the data.</param>
|
||||||
/// <param name="data">The arbitrary data to save.</param>
|
/// <param name="model">The arbitrary data to save.</param>
|
||||||
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
|
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
|
||||||
public void WriteSaveData<TModel>(string key, TModel data) where TModel : class
|
public void WriteSaveData<TModel>(string key, TModel model) where TModel : class
|
||||||
{
|
{
|
||||||
if (!Game1.hasLoadedGame)
|
if (Context.LoadStage == LoadStage.None)
|
||||||
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
|
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
|
||||||
if (!Game1.IsMasterGame)
|
if (!Game1.IsMasterGame)
|
||||||
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
|
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
|
||||||
|
|
||||||
string internalKey = this.GetSaveFileKey(key);
|
string internalKey = this.GetSaveFileKey(key);
|
||||||
|
string data = model != null
|
||||||
|
? this.JsonHelper.Serialize(model, Formatting.None)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
|
||||||
|
{
|
||||||
if (data != null)
|
if (data != null)
|
||||||
Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None);
|
dataField[internalKey] = data;
|
||||||
else
|
else
|
||||||
Game1.CustomData.Remove(internalKey);
|
dataField.Remove(internalKey);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/****
|
/****
|
||||||
|
@ -146,6 +160,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
|
||||||
return $"smapi/mod-data/{this.ModID}/{key}".ToLower();
|
return $"smapi/mod-data/{this.ModID}/{key}".ToLower();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Get the data fields to read/write for save data.</summary>
|
||||||
|
/// <param name="stage">The current load stage.</param>
|
||||||
|
private IEnumerable<IDictionary<string, string>> GetDataFields(LoadStage stage)
|
||||||
|
{
|
||||||
|
if (stage == LoadStage.None)
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
yield return Game1.CustomData;
|
||||||
|
if (SaveGame.loaded != null)
|
||||||
|
yield return SaveGame.loaded.CustomData;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Get the absolute path for a global data file.</summary>
|
/// <summary>Get the absolute path for a global data file.</summary>
|
||||||
/// <param name="key">The unique key identifying the data.</param>
|
/// <param name="key">The unique key identifying the data.</param>
|
||||||
private string GetGlobalDataPath(string key)
|
private string GetGlobalDataPath(string key)
|
||||||
|
|
|
@ -356,6 +356,11 @@ namespace StardewModdingAPI.Framework.ModLoading
|
||||||
mod.SetWarning(ModWarning.UsesDynamic);
|
mod.SetWarning(ModWarning.UsesDynamic);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case InstructionHandleResult.DetectedConsoleAccess:
|
||||||
|
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected direct console access ({handler.NounPhrase}) in assembly {filename}.");
|
||||||
|
mod.SetWarning(ModWarning.AccessesConsole);
|
||||||
|
break;
|
||||||
|
|
||||||
case InstructionHandleResult.DetectedFilesystemAccess:
|
case InstructionHandleResult.DetectedFilesystemAccess:
|
||||||
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}.");
|
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}.");
|
||||||
mod.SetWarning(ModWarning.AccessesFilesystem);
|
mod.SetWarning(ModWarning.AccessesFilesystem);
|
||||||
|
|
|
@ -26,6 +26,9 @@ namespace StardewModdingAPI.Framework.ModLoading
|
||||||
/// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary>
|
/// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary>
|
||||||
DetectedUnvalidatedUpdateTick,
|
DetectedUnvalidatedUpdateTick,
|
||||||
|
|
||||||
|
/// <summary>The instruction accesses the SMAPI console directly.</summary>
|
||||||
|
DetectedConsoleAccess,
|
||||||
|
|
||||||
/// <summary>The instruction accesses the filesystem directly.</summary>
|
/// <summary>The instruction accesses the filesystem directly.</summary>
|
||||||
DetectedFilesystemAccess,
|
DetectedFilesystemAccess,
|
||||||
|
|
||||||
|
|
|
@ -188,6 +188,27 @@ namespace StardewModdingAPI.Framework.ModLoading
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Get the mod IDs that must be installed to load this mod.</summary>
|
||||||
|
/// <param name="includeOptional">Whether to include optional dependencies.</param>
|
||||||
|
public IEnumerable<string> GetRequiredModIds(bool includeOptional = false)
|
||||||
|
{
|
||||||
|
HashSet<string> required = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
|
||||||
|
|
||||||
|
// yield dependencies
|
||||||
|
if (this.Manifest?.Dependencies != null)
|
||||||
|
{
|
||||||
|
foreach (var entry in this.Manifest?.Dependencies)
|
||||||
|
{
|
||||||
|
if ((entry.IsRequired || includeOptional) && required.Add(entry.UniqueID))
|
||||||
|
yield return entry.UniqueID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// yield content pack parent
|
||||||
|
if (this.Manifest?.ContentPackFor?.UniqueID != null && required.Add(this.Manifest.ContentPackFor.UniqueID))
|
||||||
|
yield return this.Manifest.ContentPackFor.UniqueID;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Whether the mod has at least one valid update key set.</summary>
|
/// <summary>Whether the mod has at least one valid update key set.</summary>
|
||||||
public bool HasValidUpdateKeys()
|
public bool HasValidUpdateKeys()
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
namespace StardewModdingAPI.Framework.Models
|
|
||||||
{
|
|
||||||
/// <summary>Metadata exported to the mod folder.</summary>
|
|
||||||
internal class ModFolderExport
|
|
||||||
{
|
|
||||||
/// <summary>When the export was generated.</summary>
|
|
||||||
public string Exported { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The absolute path of the mod folder.</summary>
|
|
||||||
public string ModFolderPath { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The game version which last loaded the mods.</summary>
|
|
||||||
public string GameVersion { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The SMAPI version which last loaded the mods.</summary>
|
|
||||||
public string ApiVersion { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The detected mods.</summary>
|
|
||||||
public IModMetadata[] Mods { get; set; }
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -25,8 +25,7 @@ namespace StardewModdingAPI.Framework.Models
|
||||||
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
|
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
|
||||||
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
|
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
|
||||||
[nameof(VerboseLogging)] = false,
|
[nameof(VerboseLogging)] = false,
|
||||||
[nameof(LogNetworkTraffic)] = false,
|
[nameof(LogNetworkTraffic)] = false
|
||||||
[nameof(DumpMetadata)] = false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
|
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
|
||||||
|
@ -64,9 +63,6 @@ namespace StardewModdingAPI.Framework.Models
|
||||||
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
|
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
|
||||||
public bool LogNetworkTraffic { get; set; }
|
public bool LogNetworkTraffic { get; set; }
|
||||||
|
|
||||||
/// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary>
|
|
||||||
public bool DumpMetadata { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The colors to use for text written to the SMAPI console.</summary>
|
/// <summary>The colors to use for text written to the SMAPI console.</summary>
|
||||||
public ColorSchemeConfig ConsoleColors { get; set; }
|
public ColorSchemeConfig ConsoleColors { get; set; }
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,10 @@ namespace StardewModdingAPI.Framework.Reflection
|
||||||
{
|
{
|
||||||
result = this.MethodInfo.Invoke(this.Parent, arguments);
|
result = this.MethodInfo.Invoke(this.Parent, arguments);
|
||||||
}
|
}
|
||||||
|
catch (TargetParameterCountException)
|
||||||
|
{
|
||||||
|
throw new Exception($"Couldn't invoke the {this.DisplayName} method: it expects {this.MethodInfo.GetParameters().Length} parameters, but {arguments.Length} were provided.");
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex);
|
throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex);
|
||||||
|
|
|
@ -97,16 +97,25 @@ namespace StardewModdingAPI.Framework
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Regex patterns which match console messages to show a more friendly error for.</summary>
|
/// <summary>Regex patterns which match console messages to show a more friendly error for.</summary>
|
||||||
private readonly Tuple<Regex, string, LogLevel>[] ReplaceConsolePatterns =
|
private readonly ReplaceLogPattern[] ReplaceConsolePatterns =
|
||||||
{
|
{
|
||||||
Tuple.Create(
|
// Steam not loaded
|
||||||
new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.", RegexOptions.Compiled | RegexOptions.CultureInvariant),
|
new ReplaceLogPattern(
|
||||||
|
search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
|
||||||
|
replacement:
|
||||||
#if SMAPI_FOR_WINDOWS
|
#if SMAPI_FOR_WINDOWS
|
||||||
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).",
|
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).",
|
||||||
#else
|
#else
|
||||||
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
|
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
|
||||||
#endif
|
#endif
|
||||||
LogLevel.Error
|
logLevel: LogLevel.Error
|
||||||
|
),
|
||||||
|
|
||||||
|
// save file not found error
|
||||||
|
new ReplaceLogPattern(
|
||||||
|
search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
|
||||||
|
replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.",
|
||||||
|
logLevel: LogLevel.Error
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -426,20 +435,6 @@ namespace StardewModdingAPI.Framework
|
||||||
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
|
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
|
||||||
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
|
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
|
||||||
|
|
||||||
// write metadata file
|
|
||||||
if (this.Settings.DumpMetadata)
|
|
||||||
{
|
|
||||||
ModFolderExport export = new ModFolderExport
|
|
||||||
{
|
|
||||||
Exported = DateTime.UtcNow.ToString("O"),
|
|
||||||
ApiVersion = Constants.ApiVersion.ToString(),
|
|
||||||
GameVersion = Constants.GameVersion.ToString(),
|
|
||||||
ModFolderPath = this.ModsPath,
|
|
||||||
Mods = mods
|
|
||||||
};
|
|
||||||
this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export);
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for updates
|
// check for updates
|
||||||
this.CheckForUpdatesAsync(mods);
|
this.CheckForUpdatesAsync(mods);
|
||||||
}
|
}
|
||||||
|
@ -774,7 +769,7 @@ namespace StardewModdingAPI.Framework
|
||||||
this.Monitor.Log(
|
this.Monitor.Log(
|
||||||
$" {metadata.DisplayName} {manifest.Version}"
|
$" {metadata.DisplayName} {manifest.Version}"
|
||||||
+ (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
|
+ (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
|
||||||
+ (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "")
|
+ $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}"
|
||||||
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
|
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
|
||||||
LogLevel.Info
|
LogLevel.Info
|
||||||
);
|
);
|
||||||
|
@ -842,32 +837,9 @@ namespace StardewModdingAPI.Framework
|
||||||
{
|
{
|
||||||
if (metadata.Mod.Helper.Content is ContentHelper helper)
|
if (metadata.Mod.Helper.Content is ContentHelper helper)
|
||||||
{
|
{
|
||||||
helper.ObservableAssetEditors.CollectionChanged += (sender, e) =>
|
helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
|
||||||
{
|
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
|
||||||
if (e.NewItems?.Count > 0)
|
|
||||||
{
|
|
||||||
this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace);
|
|
||||||
this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) =>
|
|
||||||
{
|
|
||||||
if (e.NewItems?.Count > 0)
|
|
||||||
{
|
|
||||||
this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace);
|
|
||||||
this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// reset cache now if any editors or loaders were added during entry
|
|
||||||
IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray();
|
|
||||||
IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray();
|
|
||||||
if (editors.Any() || loaders.Any())
|
|
||||||
{
|
|
||||||
this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace);
|
|
||||||
this.ContentCore.InvalidateCacheFor(editors, loaders);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlock mod integrations
|
// unlock mod integrations
|
||||||
|
@ -1060,26 +1032,48 @@ namespace StardewModdingAPI.Framework
|
||||||
// log skipped mods
|
// log skipped mods
|
||||||
if (skippedMods.Any())
|
if (skippedMods.Any())
|
||||||
{
|
{
|
||||||
|
// get logging logic
|
||||||
|
HashSet<string> logged = new HashSet<string>();
|
||||||
|
void LogSkippedMod(IModMetadata mod, string errorReason, string errorDetails)
|
||||||
|
{
|
||||||
|
string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
|
||||||
|
|
||||||
|
if (logged.Add($"{message}|{errorDetails}"))
|
||||||
|
{
|
||||||
|
this.Monitor.Log(message, LogLevel.Error);
|
||||||
|
if (errorDetails != null)
|
||||||
|
this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// find skipped dependencies
|
||||||
|
KeyValuePair<IModMetadata, Tuple<string, string>>[] skippedDependencies;
|
||||||
|
{
|
||||||
|
HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
|
||||||
|
HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.Key.HasID() select mod.Key.Manifest.UniqueID, StringComparer.InvariantCultureIgnoreCase);
|
||||||
|
foreach (IModMetadata mod in skippedMods.Keys)
|
||||||
|
{
|
||||||
|
foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds()))
|
||||||
|
skippedDependencyIds.Add(requiredId);
|
||||||
|
}
|
||||||
|
skippedDependencies = skippedMods.Where(p => p.Key.HasID() && skippedDependencyIds.Contains(p.Key.Manifest.UniqueID)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// log skipped mods
|
||||||
this.Monitor.Log(" Skipped mods", LogLevel.Error);
|
this.Monitor.Log(" Skipped mods", LogLevel.Error);
|
||||||
this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error);
|
this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error);
|
||||||
this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
|
this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
|
||||||
this.Monitor.Newline();
|
this.Monitor.Newline();
|
||||||
|
|
||||||
HashSet<string> logged = new HashSet<string>();
|
if (skippedDependencies.Any())
|
||||||
foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
|
|
||||||
{
|
{
|
||||||
IModMetadata mod = pair.Key;
|
foreach (var pair in skippedDependencies.OrderBy(p => p.Key.DisplayName))
|
||||||
string errorReason = pair.Value.Item1;
|
LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
|
||||||
string errorDetails = pair.Value.Item2;
|
this.Monitor.Newline();
|
||||||
string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
|
|
||||||
|
|
||||||
if (!logged.Add($"{message}|{errorDetails}"))
|
|
||||||
continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed)
|
|
||||||
|
|
||||||
this.Monitor.Log(message, LogLevel.Error);
|
|
||||||
if (errorDetails != null)
|
|
||||||
this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
|
||||||
|
LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
|
||||||
this.Monitor.Newline();
|
this.Monitor.Newline();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1116,6 +1110,10 @@ namespace StardewModdingAPI.Framework
|
||||||
);
|
);
|
||||||
if (this.Settings.ParanoidWarnings)
|
if (this.Settings.ParanoidWarnings)
|
||||||
{
|
{
|
||||||
|
LogWarningGroup(ModWarning.AccessesConsole, LogLevel.Warn, "Accesses the console directly",
|
||||||
|
"These mods directly access the SMAPI console, and you enabled paranoid warnings. (Note that this may be",
|
||||||
|
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
|
||||||
|
);
|
||||||
LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly",
|
LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly",
|
||||||
"These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be",
|
"These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be",
|
||||||
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
|
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
|
||||||
|
@ -1317,11 +1315,12 @@ namespace StardewModdingAPI.Framework
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// show friendly error if applicable
|
// show friendly error if applicable
|
||||||
foreach (var entry in this.ReplaceConsolePatterns)
|
foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns)
|
||||||
{
|
{
|
||||||
if (entry.Item1.IsMatch(message))
|
string newMessage = entry.Search.Replace(message, entry.Replacement);
|
||||||
|
if (message != newMessage)
|
||||||
{
|
{
|
||||||
this.Monitor.Log(entry.Item2, entry.Item3);
|
gameMonitor.Log(newMessage, entry.LogLevel);
|
||||||
gameMonitor.Log(message, LogLevel.Trace);
|
gameMonitor.Log(message, LogLevel.Trace);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -1411,5 +1410,36 @@ namespace StardewModdingAPI.Framework
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A console log pattern to replace with a different message.</summary>
|
||||||
|
private class ReplaceLogPattern
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>The regex pattern matching the portion of the message to replace.</summary>
|
||||||
|
public Regex Search { get; }
|
||||||
|
|
||||||
|
/// <summary>The replacement string.</summary>
|
||||||
|
public string Replacement { get; }
|
||||||
|
|
||||||
|
/// <summary>The log level for the new message.</summary>
|
||||||
|
public LogLevel LogLevel { get; }
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="search">The regex pattern matching the portion of the message to replace.</param>
|
||||||
|
/// <param name="replacement">The replacement string.</param>
|
||||||
|
/// <param name="logLevel">The log level for the new message.</param>
|
||||||
|
public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel)
|
||||||
|
{
|
||||||
|
this.Search = search;
|
||||||
|
this.Replacement = replacement;
|
||||||
|
this.LogLevel = logLevel;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
|
using System.Collections;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
|
@ -12,10 +13,12 @@ using Microsoft.Xna.Framework.Graphics;
|
||||||
using Netcode;
|
using Netcode;
|
||||||
using StardewModdingAPI.Enums;
|
using StardewModdingAPI.Enums;
|
||||||
using StardewModdingAPI.Events;
|
using StardewModdingAPI.Events;
|
||||||
|
using StardewModdingAPI.Framework.Content;
|
||||||
using StardewModdingAPI.Framework.Events;
|
using StardewModdingAPI.Framework.Events;
|
||||||
using StardewModdingAPI.Framework.Input;
|
using StardewModdingAPI.Framework.Input;
|
||||||
using StardewModdingAPI.Framework.Networking;
|
using StardewModdingAPI.Framework.Networking;
|
||||||
using StardewModdingAPI.Framework.Reflection;
|
using StardewModdingAPI.Framework.Reflection;
|
||||||
|
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||||
using StardewModdingAPI.Framework.StateTracking.Snapshots;
|
using StardewModdingAPI.Framework.StateTracking.Snapshots;
|
||||||
using StardewModdingAPI.Framework.Utilities;
|
using StardewModdingAPI.Framework.Utilities;
|
||||||
using StardewModdingAPI.Toolkit.Serialization;
|
using StardewModdingAPI.Toolkit.Serialization;
|
||||||
|
@ -99,7 +102,7 @@ namespace StardewModdingAPI.Framework
|
||||||
private WatcherCore Watchers;
|
private WatcherCore Watchers;
|
||||||
|
|
||||||
/// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
|
/// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
|
||||||
private WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
|
private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
|
||||||
|
|
||||||
/// <summary>Whether post-game-startup initialization has been performed.</summary>
|
/// <summary>Whether post-game-startup initialization has been performed.</summary>
|
||||||
private bool IsInitialized;
|
private bool IsInitialized;
|
||||||
|
@ -133,6 +136,9 @@ namespace StardewModdingAPI.Framework
|
||||||
/// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
|
/// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
|
||||||
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
|
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
|
||||||
|
|
||||||
|
/// <summary>Asset interceptors added or removed since the last tick.</summary>
|
||||||
|
private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Protected methods
|
** Protected methods
|
||||||
|
@ -249,6 +255,24 @@ namespace StardewModdingAPI.Framework
|
||||||
this.Events.ReturnedToTitle.RaiseEmpty();
|
this.Events.ReturnedToTitle.RaiseEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A callback invoked when a mod adds or removes an asset interceptor.</summary>
|
||||||
|
/// <param name="mod">The mod which added or removed interceptors.</param>
|
||||||
|
/// <param name="added">The added interceptors.</param>
|
||||||
|
/// <param name="removed">The removed interceptors.</param>
|
||||||
|
internal void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable added, IEnumerable removed)
|
||||||
|
{
|
||||||
|
if (added != null)
|
||||||
|
{
|
||||||
|
foreach (object instance in added)
|
||||||
|
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: true));
|
||||||
|
}
|
||||||
|
if (removed != null)
|
||||||
|
{
|
||||||
|
foreach (object instance in removed)
|
||||||
|
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Constructor a content manager to read XNB files.</summary>
|
/// <summary>Constructor a content manager to read XNB files.</summary>
|
||||||
/// <param name="serviceProvider">The service provider to use to locate services.</param>
|
/// <param name="serviceProvider">The service provider to use to locate services.</param>
|
||||||
/// <param name="rootDirectory">The root directory to search for content.</param>
|
/// <param name="rootDirectory">The root directory to search for content.</param>
|
||||||
|
@ -404,6 +428,38 @@ namespace StardewModdingAPI.Framework
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Reload assets when interceptors are added/removed
|
||||||
|
*********/
|
||||||
|
if (this.ReloadAssetInterceptorsQueue.Any())
|
||||||
|
{
|
||||||
|
// get unique interceptors
|
||||||
|
AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
|
||||||
|
.GroupBy(p => p.Instance, new ObjectReferenceComparer<object>())
|
||||||
|
.Select(p => p.First())
|
||||||
|
.ToArray();
|
||||||
|
this.ReloadAssetInterceptorsQueue.Clear();
|
||||||
|
|
||||||
|
// log summary
|
||||||
|
this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
|
||||||
|
this.Monitor.Log(
|
||||||
|
" changed: "
|
||||||
|
+ string.Join(", ",
|
||||||
|
interceptors
|
||||||
|
.GroupBy(p => p.Mod)
|
||||||
|
.OrderBy(p => p.Key.DisplayName)
|
||||||
|
.Select(modGroup =>
|
||||||
|
$"{modGroup.Key.DisplayName} ("
|
||||||
|
+ string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
|
||||||
|
+ ")"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// reload affected assets
|
||||||
|
this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
|
||||||
|
}
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Execute commands
|
** Execute commands
|
||||||
*********/
|
*********/
|
||||||
|
@ -654,6 +710,16 @@ namespace StardewModdingAPI.Framework
|
||||||
if (locState.Objects.IsChanged)
|
if (locState.Objects.IsChanged)
|
||||||
events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed));
|
events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed));
|
||||||
|
|
||||||
|
// chest items changed
|
||||||
|
if (events.ChestInventoryChanged.HasListeners())
|
||||||
|
{
|
||||||
|
foreach (var pair in locState.ChestItems)
|
||||||
|
{
|
||||||
|
SnapshotItemListDiff diff = pair.Value;
|
||||||
|
events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// terrain features changed
|
// terrain features changed
|
||||||
if (locState.TerrainFeatures.IsChanged)
|
if (locState.TerrainFeatures.IsChanged)
|
||||||
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
|
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
|
||||||
|
@ -692,12 +758,13 @@ namespace StardewModdingAPI.Framework
|
||||||
}
|
}
|
||||||
|
|
||||||
// raise player inventory changed
|
// raise player inventory changed
|
||||||
ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray();
|
if (playerState.Inventory.IsChanged)
|
||||||
if (changedItems.Any())
|
|
||||||
{
|
{
|
||||||
|
var inventory = playerState.Inventory;
|
||||||
|
|
||||||
if (this.Monitor.IsVerbose)
|
if (this.Monitor.IsVerbose)
|
||||||
this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
|
this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
|
||||||
events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, changedItems));
|
events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StardewModdingAPI.Events;
|
||||||
|
using StardewValley;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Framework
|
||||||
|
{
|
||||||
|
/// <summary>A snapshot of a tracked item list.</summary>
|
||||||
|
internal class SnapshotItemListDiff
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>Whether the item list changed.</summary>
|
||||||
|
public bool IsChanged { get; }
|
||||||
|
|
||||||
|
/// <summary>The removed values.</summary>
|
||||||
|
public Item[] Removed { get; }
|
||||||
|
|
||||||
|
/// <summary>The added values.</summary>
|
||||||
|
public Item[] Added { get; }
|
||||||
|
|
||||||
|
/// <summary>The items whose stack sizes changed.</summary>
|
||||||
|
public ItemStackSizeChange[] QuantityChanged { get; }
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Update the snapshot.</summary>
|
||||||
|
/// <param name="added">The added values.</param>
|
||||||
|
/// <param name="removed">The removed values.</param>
|
||||||
|
/// <param name="sizesChanged">The items whose stack sizes changed.</param>
|
||||||
|
public SnapshotItemListDiff(Item[] added, Item[] removed, ItemStackSizeChange[] sizesChanged)
|
||||||
|
{
|
||||||
|
this.Removed = removed;
|
||||||
|
this.Added = added;
|
||||||
|
this.QuantityChanged = sizesChanged;
|
||||||
|
|
||||||
|
this.IsChanged = removed.Length > 0 || added.Length > 0 || sizesChanged.Length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a snapshot diff if anything changed in the given data.</summary>
|
||||||
|
/// <param name="added">The added item stacks.</param>
|
||||||
|
/// <param name="removed">The removed item stacks.</param>
|
||||||
|
/// <param name="stackSizes">The items with their previous stack sizes.</param>
|
||||||
|
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
|
||||||
|
/// <returns>Returns whether anything changed.</returns>
|
||||||
|
public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, out SnapshotItemListDiff changes)
|
||||||
|
{
|
||||||
|
KeyValuePair<Item, int>[] sizesChanged = stackSizes.Where(p => p.Key.Stack != p.Value).ToArray();
|
||||||
|
if (sizesChanged.Any() || added.Any() || removed.Any())
|
||||||
|
{
|
||||||
|
changes = new SnapshotItemListDiff(
|
||||||
|
added: added.ToArray(),
|
||||||
|
removed: removed.ToArray(),
|
||||||
|
sizesChanged: sizesChanged.Select(p => new ItemStackSizeChange(p.Key, p.Value, p.Key.Stack)).ToArray()
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
changes = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,101 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||||
|
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
|
||||||
|
using StardewValley;
|
||||||
|
using StardewValley.Objects;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Framework.StateTracking
|
||||||
|
{
|
||||||
|
/// <summary>Tracks changes to a chest's items.</summary>
|
||||||
|
internal class ChestTracker : IDisposable
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Fields
|
||||||
|
*********/
|
||||||
|
/// <summary>The item stack sizes as of the last update.</summary>
|
||||||
|
private readonly IDictionary<Item, int> StackSizes;
|
||||||
|
|
||||||
|
/// <summary>Items added since the last update.</summary>
|
||||||
|
private readonly HashSet<Item> Added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
|
||||||
|
|
||||||
|
/// <summary>Items removed since the last update.</summary>
|
||||||
|
private readonly HashSet<Item> Removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
|
||||||
|
|
||||||
|
/// <summary>The underlying inventory watcher.</summary>
|
||||||
|
private readonly ICollectionWatcher<Item> InventoryWatcher;
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>The chest being tracked.</summary>
|
||||||
|
public Chest Chest { get; }
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="chest">The chest being tracked.</param>
|
||||||
|
public ChestTracker(Chest chest)
|
||||||
|
{
|
||||||
|
this.Chest = chest;
|
||||||
|
this.InventoryWatcher = WatcherFactory.ForNetList(chest.items);
|
||||||
|
|
||||||
|
this.StackSizes = this.Chest.items
|
||||||
|
.Where(n => n != null)
|
||||||
|
.Distinct()
|
||||||
|
.ToDictionary(n => n, n => n.Stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Update the current values if needed.</summary>
|
||||||
|
public void Update()
|
||||||
|
{
|
||||||
|
// update watcher
|
||||||
|
this.InventoryWatcher.Update();
|
||||||
|
foreach (Item item in this.InventoryWatcher.Added)
|
||||||
|
this.Added.Add(item);
|
||||||
|
foreach (Item item in this.InventoryWatcher.Removed)
|
||||||
|
{
|
||||||
|
if (!this.Added.Remove(item)) // item didn't change if it was both added and removed, so remove it from both lists
|
||||||
|
this.Removed.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop tracking removed stacks
|
||||||
|
foreach (Item item in this.Removed)
|
||||||
|
this.StackSizes.Remove(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reset all trackers so their current values are the baseline.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
// update stack sizes
|
||||||
|
foreach (Item item in this.StackSizes.Keys.ToArray().Concat(this.Added))
|
||||||
|
this.StackSizes[item] = item.Stack;
|
||||||
|
|
||||||
|
// update watcher
|
||||||
|
this.InventoryWatcher.Reset();
|
||||||
|
this.Added.Clear();
|
||||||
|
this.Removed.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Get the inventory changes since the last update, if anything changed.</summary>
|
||||||
|
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
|
||||||
|
/// <returns>Returns whether anything changed.</returns>
|
||||||
|
public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
|
||||||
|
{
|
||||||
|
return SnapshotItemListDiff.TryGetChanges(added: this.Added, removed: this.Removed, stackSizes: this.StackSizes, out changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Release watchers and resources.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this.StackSizes.Clear();
|
||||||
|
this.Added.Clear();
|
||||||
|
this.Removed.Clear();
|
||||||
|
this.InventoryWatcher.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,143 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Netcode;
|
||||||
|
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||||
|
|
||||||
|
namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
|
||||||
|
{
|
||||||
|
/// <summary>A watcher which detects changes to a net list field.</summary>
|
||||||
|
/// <typeparam name="TValue">The list value type.</typeparam>
|
||||||
|
internal class NetListWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue>
|
||||||
|
where TValue : class, INetObject<INetSerializable>
|
||||||
|
{
|
||||||
|
/*********
|
||||||
|
** Fields
|
||||||
|
*********/
|
||||||
|
/// <summary>The field being watched.</summary>
|
||||||
|
private readonly NetList<TValue, NetRef<TValue>> Field;
|
||||||
|
|
||||||
|
/// <summary>The pairs added since the last reset.</summary>
|
||||||
|
private readonly ISet<TValue> AddedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
|
||||||
|
|
||||||
|
/// <summary>The pairs removed since the last reset.</summary>
|
||||||
|
private readonly ISet<TValue> RemovedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Accessors
|
||||||
|
*********/
|
||||||
|
/// <summary>Whether the collection changed since the last reset.</summary>
|
||||||
|
public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The values added since the last reset.</summary>
|
||||||
|
public IEnumerable<TValue> Added => this.AddedImpl;
|
||||||
|
|
||||||
|
/// <summary>The values removed since the last reset.</summary>
|
||||||
|
public IEnumerable<TValue> Removed => this.RemovedImpl;
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Public methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Construct an instance.</summary>
|
||||||
|
/// <param name="field">The field to watch.</param>
|
||||||
|
public NetListWatcher(NetList<TValue, NetRef<TValue>> field)
|
||||||
|
{
|
||||||
|
this.Field = field;
|
||||||
|
field.OnElementChanged += this.OnElementChanged;
|
||||||
|
field.OnArrayReplaced += this.OnArrayReplaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Set the current value as the baseline.</summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
this.AddedImpl.Clear();
|
||||||
|
this.RemovedImpl.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Update the current value if needed.</summary>
|
||||||
|
public void Update()
|
||||||
|
{
|
||||||
|
this.AssertNotDisposed();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop watching the field and release all references.</summary>
|
||||||
|
public override void Dispose()
|
||||||
|
{
|
||||||
|
if (!this.IsDisposed)
|
||||||
|
{
|
||||||
|
this.Field.OnElementChanged -= this.OnElementChanged;
|
||||||
|
this.Field.OnArrayReplaced -= this.OnArrayReplaced;
|
||||||
|
}
|
||||||
|
|
||||||
|
base.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Private methods
|
||||||
|
*********/
|
||||||
|
/// <summary>A callback invoked when the value list is replaced.</summary>
|
||||||
|
/// <param name="list">The net field whose values changed.</param>
|
||||||
|
/// <param name="oldValues">The previous list of values.</param>
|
||||||
|
/// <param name="newValues">The new list of values.</param>
|
||||||
|
private void OnArrayReplaced(NetList<TValue, NetRef<TValue>> list, IList<TValue> oldValues, IList<TValue> newValues)
|
||||||
|
{
|
||||||
|
ISet<TValue> oldSet = new HashSet<TValue>(oldValues, new ObjectReferenceComparer<TValue>());
|
||||||
|
ISet<TValue> changed = new HashSet<TValue>(newValues, new ObjectReferenceComparer<TValue>());
|
||||||
|
|
||||||
|
foreach (TValue value in oldSet)
|
||||||
|
{
|
||||||
|
if (!changed.Contains(value))
|
||||||
|
this.Remove(value);
|
||||||
|
}
|
||||||
|
foreach (TValue value in changed)
|
||||||
|
{
|
||||||
|
if (!oldSet.Contains(value))
|
||||||
|
this.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A callback invoked when an entry is replaced.</summary>
|
||||||
|
/// <param name="list">The net field whose values changed.</param>
|
||||||
|
/// <param name="index">The list index which changed.</param>
|
||||||
|
/// <param name="oldValue">The previous value.</param>
|
||||||
|
/// <param name="newValue">The new value.</param>
|
||||||
|
private void OnElementChanged(NetList<TValue, NetRef<TValue>> list, int index, TValue oldValue, TValue newValue)
|
||||||
|
{
|
||||||
|
this.Remove(oldValue);
|
||||||
|
this.Add(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Track an added item.</summary>
|
||||||
|
/// <param name="value">The value that was added.</param>
|
||||||
|
private void Add(TValue value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.RemovedImpl.Contains(value))
|
||||||
|
{
|
||||||
|
this.AddedImpl.Remove(value);
|
||||||
|
this.RemovedImpl.Remove(value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.AddedImpl.Add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Track a removed item.</summary>
|
||||||
|
/// <param name="value">The value that was removed.</param>
|
||||||
|
private void Remove(TValue value)
|
||||||
|
{
|
||||||
|
if (value == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (this.AddedImpl.Contains(value))
|
||||||
|
{
|
||||||
|
this.AddedImpl.Remove(value);
|
||||||
|
this.RemovedImpl.Remove(value);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
this.RemovedImpl.Add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
|
||||||
/// <summary>The pairs removed since the last reset.</summary>
|
/// <summary>The pairs removed since the last reset.</summary>
|
||||||
private readonly List<TValue> RemovedImpl = new List<TValue>();
|
private readonly List<TValue> RemovedImpl = new List<TValue>();
|
||||||
|
|
||||||
|
/// <summary>The previous values as of the last update.</summary>
|
||||||
|
private readonly List<TValue> PreviousValues = new List<TValue>();
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Accessors
|
** Accessors
|
||||||
|
@ -78,10 +81,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
|
||||||
/// <param name="e">The event arguments.</param>
|
/// <param name="e">The event arguments.</param>
|
||||||
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.NewItems != null)
|
if (e.Action == NotifyCollectionChangedAction.Reset)
|
||||||
this.AddedImpl.AddRange(e.NewItems.Cast<TValue>());
|
{
|
||||||
if (e.OldItems != null)
|
this.RemovedImpl.AddRange(this.PreviousValues);
|
||||||
this.RemovedImpl.AddRange(e.OldItems.Cast<TValue>());
|
this.PreviousValues.Clear();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TValue[] added = e.NewItems?.Cast<TValue>().ToArray();
|
||||||
|
TValue[] removed = e.OldItems?.Cast<TValue>().ToArray();
|
||||||
|
|
||||||
|
if (removed != null)
|
||||||
|
{
|
||||||
|
this.RemovedImpl.AddRange(removed);
|
||||||
|
this.PreviousValues.RemoveRange(e.OldStartingIndex, removed.Length);
|
||||||
|
}
|
||||||
|
if (added != null)
|
||||||
|
{
|
||||||
|
this.AddedImpl.AddRange(added);
|
||||||
|
this.PreviousValues.InsertRange(e.NewStartingIndex, added);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -82,6 +82,14 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
|
||||||
return new NetCollectionWatcher<T>(collection);
|
return new NetCollectionWatcher<T>(collection);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Get a watcher for a net list.</summary>
|
||||||
|
/// <typeparam name="T">The value type.</typeparam>
|
||||||
|
/// <param name="collection">The net list.</param>
|
||||||
|
public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) where T : class, INetObject<INetSerializable>
|
||||||
|
{
|
||||||
|
return new NetListWatcher<T>(collection);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Get a watcher for a net dictionary.</summary>
|
/// <summary>Get a watcher for a net dictionary.</summary>
|
||||||
/// <typeparam name="TKey">The dictionary key type.</typeparam>
|
/// <typeparam name="TKey">The dictionary key type.</typeparam>
|
||||||
/// <typeparam name="TValue">The dictionary value type.</typeparam>
|
/// <typeparam name="TValue">The dictionary value type.</typeparam>
|
||||||
|
|
|
@ -5,8 +5,9 @@ using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
using StardewValley.Buildings;
|
using StardewValley.Buildings;
|
||||||
using StardewValley.Locations;
|
using StardewValley.Locations;
|
||||||
|
using StardewValley.Objects;
|
||||||
using StardewValley.TerrainFeatures;
|
using StardewValley.TerrainFeatures;
|
||||||
using Object = StardewValley.Object;
|
using SObject = StardewValley.Object;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Framework.StateTracking
|
namespace StardewModdingAPI.Framework.StateTracking
|
||||||
{
|
{
|
||||||
|
@ -42,11 +43,14 @@ namespace StardewModdingAPI.Framework.StateTracking
|
||||||
public ICollectionWatcher<NPC> NpcsWatcher { get; }
|
public ICollectionWatcher<NPC> NpcsWatcher { get; }
|
||||||
|
|
||||||
/// <summary>Tracks added or removed objects.</summary>
|
/// <summary>Tracks added or removed objects.</summary>
|
||||||
public IDictionaryWatcher<Vector2, Object> ObjectsWatcher { get; }
|
public IDictionaryWatcher<Vector2, SObject> ObjectsWatcher { get; }
|
||||||
|
|
||||||
/// <summary>Tracks added or removed terrain features.</summary>
|
/// <summary>Tracks added or removed terrain features.</summary>
|
||||||
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
|
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
|
||||||
|
|
||||||
|
/// <summary>Tracks items added or removed to chests.</summary>
|
||||||
|
public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>();
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Public methods
|
** Public methods
|
||||||
|
@ -74,13 +78,8 @@ namespace StardewModdingAPI.Framework.StateTracking
|
||||||
this.ObjectsWatcher,
|
this.ObjectsWatcher,
|
||||||
this.TerrainFeaturesWatcher
|
this.TerrainFeaturesWatcher
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Stop watching the player fields and release all references.</summary>
|
this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
foreach (IWatcher watcher in this.Watchers)
|
|
||||||
watcher.Dispose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Update the current value if needed.</summary>
|
/// <summary>Update the current value if needed.</summary>
|
||||||
|
@ -88,6 +87,11 @@ namespace StardewModdingAPI.Framework.StateTracking
|
||||||
{
|
{
|
||||||
foreach (IWatcher watcher in this.Watchers)
|
foreach (IWatcher watcher in this.Watchers)
|
||||||
watcher.Update();
|
watcher.Update();
|
||||||
|
|
||||||
|
this.UpdateChestWatcherList(added: this.ObjectsWatcher.Added, removed: this.ObjectsWatcher.Removed);
|
||||||
|
|
||||||
|
foreach (var watcher in this.ChestWatchers)
|
||||||
|
watcher.Value.Update();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Set the current value as the baseline.</summary>
|
/// <summary>Set the current value as the baseline.</summary>
|
||||||
|
@ -95,6 +99,46 @@ namespace StardewModdingAPI.Framework.StateTracking
|
||||||
{
|
{
|
||||||
foreach (IWatcher watcher in this.Watchers)
|
foreach (IWatcher watcher in this.Watchers)
|
||||||
watcher.Reset();
|
watcher.Reset();
|
||||||
|
|
||||||
|
foreach (var watcher in this.ChestWatchers)
|
||||||
|
watcher.Value.Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Stop watching the player fields and release all references.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (IWatcher watcher in this.Watchers)
|
||||||
|
watcher.Dispose();
|
||||||
|
|
||||||
|
foreach (var watcher in this.ChestWatchers.Values)
|
||||||
|
watcher.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*********
|
||||||
|
** Private methods
|
||||||
|
*********/
|
||||||
|
/// <summary>Update the watcher list for added or removed chests.</summary>
|
||||||
|
/// <param name="added">The objects added to the location.</param>
|
||||||
|
/// <param name="removed">The objects removed from the location.</param>
|
||||||
|
private void UpdateChestWatcherList(IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed)
|
||||||
|
{
|
||||||
|
// remove unused watchers
|
||||||
|
foreach (KeyValuePair<Vector2, SObject> pair in removed)
|
||||||
|
{
|
||||||
|
if (pair.Value is Chest && this.ChestWatchers.TryGetValue(pair.Key, out ChestTracker watcher))
|
||||||
|
{
|
||||||
|
watcher.Dispose();
|
||||||
|
this.ChestWatchers.Remove(pair.Key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add new watchers
|
||||||
|
foreach (KeyValuePair<Vector2, SObject> pair in added)
|
||||||
|
{
|
||||||
|
if (pair.Value is Chest chest && !this.ChestWatchers.ContainsKey(pair.Key))
|
||||||
|
this.ChestWatchers.Add(pair.Key, new ChestTracker(chest));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,9 @@ using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using StardewModdingAPI.Enums;
|
using StardewModdingAPI.Enums;
|
||||||
using StardewModdingAPI.Events;
|
using StardewModdingAPI.Framework.StateTracking.Comparers;
|
||||||
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
|
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
using ChangeType = StardewModdingAPI.Events.ChangeType;
|
|
||||||
|
|
||||||
namespace StardewModdingAPI.Framework.StateTracking
|
namespace StardewModdingAPI.Framework.StateTracking
|
||||||
{
|
{
|
||||||
|
@ -99,25 +98,32 @@ namespace StardewModdingAPI.Framework.StateTracking
|
||||||
return this.Player.currentLocation ?? this.LastValidLocation;
|
return this.Player.currentLocation ?? this.LastValidLocation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get the player inventory changes between two states.</summary>
|
/// <summary>Get the inventory changes since the last update, if anything changed.</summary>
|
||||||
public IEnumerable<ItemStackChange> GetInventoryChanges()
|
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
|
||||||
|
/// <returns>Returns whether anything changed.</returns>
|
||||||
|
public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
|
||||||
{
|
{
|
||||||
IDictionary<Item, int> previous = this.PreviousInventory;
|
|
||||||
IDictionary<Item, int> current = this.GetInventory();
|
IDictionary<Item, int> current = this.GetInventory();
|
||||||
foreach (Item item in previous.Keys.Union(current.Keys))
|
|
||||||
|
ISet<Item> added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
|
||||||
|
ISet<Item> removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
|
||||||
|
foreach (Item item in this.PreviousInventory.Keys.Union(current.Keys))
|
||||||
{
|
{
|
||||||
if (!previous.TryGetValue(item, out int prevStack))
|
if (!this.PreviousInventory.ContainsKey(item))
|
||||||
yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added };
|
added.Add(item);
|
||||||
else if (!current.TryGetValue(item, out int newStack))
|
else if (!current.ContainsKey(item))
|
||||||
yield return new ItemStackChange { Item = item, StackChange = -item.Stack, ChangeType = ChangeType.Removed };
|
removed.Add(item);
|
||||||
else if (prevStack != newStack)
|
|
||||||
yield return new ItemStackChange { Item = item, StackChange = newStack - prevStack, ChangeType = ChangeType.StackChange };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Stop watching the player fields and release all references.</summary>
|
return SnapshotItemListDiff.TryGetChanges(added: added, removed: removed, stackSizes: this.PreviousInventory, out changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Release watchers and resources.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
this.PreviousInventory.Clear();
|
||||||
|
this.CurrentInventory?.Clear();
|
||||||
|
|
||||||
foreach (IWatcher watcher in this.Watchers)
|
foreach (IWatcher watcher in this.Watchers)
|
||||||
watcher.Dispose();
|
watcher.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
using StardewValley.Buildings;
|
using StardewValley.Buildings;
|
||||||
|
using StardewValley.Objects;
|
||||||
using StardewValley.TerrainFeatures;
|
using StardewValley.TerrainFeatures;
|
||||||
|
|
||||||
namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
|
@ -33,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
/// <summary>Tracks added or removed terrain features.</summary>
|
/// <summary>Tracks added or removed terrain features.</summary>
|
||||||
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
|
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
|
||||||
|
|
||||||
|
/// <summary>Tracks changed chest inventories.</summary>
|
||||||
|
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Public methods
|
** Public methods
|
||||||
|
@ -48,12 +52,21 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
/// <param name="watcher">The watcher to snapshot.</param>
|
/// <param name="watcher">The watcher to snapshot.</param>
|
||||||
public void Update(LocationTracker watcher)
|
public void Update(LocationTracker watcher)
|
||||||
{
|
{
|
||||||
|
// main lists
|
||||||
this.Buildings.Update(watcher.BuildingsWatcher);
|
this.Buildings.Update(watcher.BuildingsWatcher);
|
||||||
this.Debris.Update(watcher.DebrisWatcher);
|
this.Debris.Update(watcher.DebrisWatcher);
|
||||||
this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher);
|
this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher);
|
||||||
this.Npcs.Update(watcher.NpcsWatcher);
|
this.Npcs.Update(watcher.NpcsWatcher);
|
||||||
this.Objects.Update(watcher.ObjectsWatcher);
|
this.Objects.Update(watcher.ObjectsWatcher);
|
||||||
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
|
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
|
||||||
|
|
||||||
|
// chest inventories
|
||||||
|
this.ChestItems.Clear();
|
||||||
|
foreach (ChestTracker tracker in watcher.ChestWatchers.Values)
|
||||||
|
{
|
||||||
|
if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff changes))
|
||||||
|
this.ChestItems[tracker.Chest] = changes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,13 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
/// <summary>A frozen snapshot of a tracked player.</summary>
|
/// <summary>A frozen snapshot of a tracked player.</summary>
|
||||||
internal class PlayerSnapshot
|
internal class PlayerSnapshot
|
||||||
{
|
{
|
||||||
|
/*********
|
||||||
|
** Fields
|
||||||
|
*********/
|
||||||
|
/// <summary>An empty item list diff.</summary>
|
||||||
|
private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]);
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
** Accessors
|
** Accessors
|
||||||
*********/
|
*********/
|
||||||
|
@ -27,7 +34,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
.ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
|
.ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
|
||||||
|
|
||||||
/// <summary>Get a list of inventory changes.</summary>
|
/// <summary>Get a list of inventory changes.</summary>
|
||||||
public IEnumerable<ItemStackChange> InventoryChanges { get; private set; }
|
public SnapshotItemListDiff Inventory { get; private set; }
|
||||||
|
|
||||||
|
|
||||||
/*********
|
/*********
|
||||||
|
@ -47,7 +54,11 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
|
||||||
this.Location.Update(watcher.LocationWatcher);
|
this.Location.Update(watcher.LocationWatcher);
|
||||||
foreach (var pair in this.Skills)
|
foreach (var pair in this.Skills)
|
||||||
pair.Value.Update(watcher.SkillWatchers[pair.Key]);
|
pair.Value.Update(watcher.SkillWatchers[pair.Key]);
|
||||||
this.InventoryChanges = watcher.GetInventoryChanges().ToArray();
|
|
||||||
|
this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges)
|
||||||
|
? itemChanges
|
||||||
|
: this.EmptyItemListDiff;
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Microsoft.Xna.Framework.Graphics;
|
using Microsoft.Xna.Framework.Graphics;
|
||||||
|
using Netcode;
|
||||||
using StardewModdingAPI.Framework.Reflection;
|
using StardewModdingAPI.Framework.Reflection;
|
||||||
using StardewValley;
|
using StardewValley;
|
||||||
using StardewValley.BellsAndWhistles;
|
using StardewValley.BellsAndWhistles;
|
||||||
|
@ -11,6 +12,7 @@ using StardewValley.Characters;
|
||||||
using StardewValley.GameData.Movies;
|
using StardewValley.GameData.Movies;
|
||||||
using StardewValley.Locations;
|
using StardewValley.Locations;
|
||||||
using StardewValley.Menus;
|
using StardewValley.Menus;
|
||||||
|
using StardewValley.Network;
|
||||||
using StardewValley.Objects;
|
using StardewValley.Objects;
|
||||||
using StardewValley.Projectiles;
|
using StardewValley.Projectiles;
|
||||||
using StardewValley.TerrainFeatures;
|
using StardewValley.TerrainFeatures;
|
||||||
|
@ -65,8 +67,8 @@ namespace StardewModdingAPI.Metadata
|
||||||
/// <summary>Reload one of the game's core assets (if applicable).</summary>
|
/// <summary>Reload one of the game's core assets (if applicable).</summary>
|
||||||
/// <param name="content">The content manager through which to reload the asset.</param>
|
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||||
/// <param name="assets">The asset keys and types to reload.</param>
|
/// <param name="assets">The asset keys and types to reload.</param>
|
||||||
/// <returns>Returns the number of reloaded assets.</returns>
|
/// <returns>Returns a lookup of asset names to whether they've been propagated.</returns>
|
||||||
public int Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
|
public IDictionary<string, bool> Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
|
||||||
{
|
{
|
||||||
// group into optimized lists
|
// group into optimized lists
|
||||||
var buckets = assets.GroupBy(p =>
|
var buckets = assets.GroupBy(p =>
|
||||||
|
@ -81,25 +83,26 @@ namespace StardewModdingAPI.Metadata
|
||||||
});
|
});
|
||||||
|
|
||||||
// reload assets
|
// reload assets
|
||||||
int reloaded = 0;
|
IDictionary<string, bool> propagated = assets.ToDictionary(p => p.Key, p => false, StringComparer.InvariantCultureIgnoreCase);
|
||||||
foreach (var bucket in buckets)
|
foreach (var bucket in buckets)
|
||||||
{
|
{
|
||||||
switch (bucket.Key)
|
switch (bucket.Key)
|
||||||
{
|
{
|
||||||
case AssetBucket.Sprite:
|
case AssetBucket.Sprite:
|
||||||
reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key));
|
this.ReloadNpcSprites(content, bucket.Select(p => p.Key), propagated);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case AssetBucket.Portrait:
|
case AssetBucket.Portrait:
|
||||||
reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key));
|
this.ReloadNpcPortraits(content, bucket.Select(p => p.Key), propagated);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
reloaded += bucket.Count(p => this.PropagateOther(content, p.Key, p.Value));
|
foreach (var entry in bucket)
|
||||||
|
propagated[entry.Key] = this.PropagateOther(content, entry.Key, entry.Value);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return reloaded;
|
return propagated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -193,7 +196,7 @@ namespace StardewModdingAPI.Metadata
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
case "characters\\farmer\\farmer_girl_base": // Farmer
|
case "characters\\farmer\\farmer_girl_base": // Farmer
|
||||||
case "characters\\farmer\\farmer_girl_bald":
|
case "characters\\farmer\\farmer_girl_base_bald":
|
||||||
if (Game1.player == null || Game1.player.IsMale)
|
if (Game1.player == null || Game1.player.IsMale)
|
||||||
return false;
|
return false;
|
||||||
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
|
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
|
||||||
|
@ -226,6 +229,31 @@ namespace StardewModdingAPI.Metadata
|
||||||
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
|
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
case "data\\bundles": // NetWorldState constructor
|
||||||
|
{
|
||||||
|
var bundles = this.Reflection.GetField<NetBundles>(Game1.netWorldState.Value, "bundles").GetValue();
|
||||||
|
var rewards = this.Reflection.GetField<NetIntDictionary<bool, NetBool>>(Game1.netWorldState.Value, "bundleRewards").GetValue();
|
||||||
|
foreach (var pair in content.Load<Dictionary<string, string>>(key))
|
||||||
|
{
|
||||||
|
int bundleKey = int.Parse(pair.Key.Split('/')[1]);
|
||||||
|
int rewardsCount = pair.Value.Split('/')[2].Split(' ').Length;
|
||||||
|
|
||||||
|
// add bundles
|
||||||
|
if (!bundles.TryGetValue(bundleKey, out bool[] values) || values.Length < rewardsCount)
|
||||||
|
{
|
||||||
|
values ??= new bool[0];
|
||||||
|
|
||||||
|
bundles.Remove(bundleKey);
|
||||||
|
bundles[bundleKey] = values.Concat(Enumerable.Repeat(false, rewardsCount - values.Length)).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
// add bundle rewards
|
||||||
|
if (!rewards.ContainsKey(bundleKey))
|
||||||
|
rewards[bundleKey] = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "data\\clothinginformation": // Game1.LoadContent
|
case "data\\clothinginformation": // Game1.LoadContent
|
||||||
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
|
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
|
||||||
return true;
|
return true;
|
||||||
|
@ -474,10 +502,18 @@ namespace StardewModdingAPI.Metadata
|
||||||
/****
|
/****
|
||||||
** Content\TerrainFeatures
|
** Content\TerrainFeatures
|
||||||
****/
|
****/
|
||||||
case "terrainfeatures\\flooring": // Flooring
|
case "terrainfeatures\\flooring": // from Flooring
|
||||||
Flooring.floorsTexture = content.Load<Texture2D>(key);
|
Flooring.floorsTexture = content.Load<Texture2D>(key);
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
case "terrainfeatures\\flooring_winter": // from Flooring
|
||||||
|
Flooring.floorsTextureWinter = content.Load<Texture2D>(key);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case "terrainfeatures\\grass": // from Grass
|
||||||
|
this.ReloadGrassTextures(content, key);
|
||||||
|
return true;
|
||||||
|
|
||||||
case "terrainfeatures\\hoedirt": // from HoeDirt
|
case "terrainfeatures\\hoedirt": // from HoeDirt
|
||||||
HoeDirt.lightTexture = content.Load<Texture2D>(key);
|
HoeDirt.lightTexture = content.Load<Texture2D>(key);
|
||||||
return true;
|
return true;
|
||||||
|
@ -607,7 +643,7 @@ namespace StardewModdingAPI.Metadata
|
||||||
{
|
{
|
||||||
// get buildings
|
// get buildings
|
||||||
string type = Path.GetFileName(key);
|
string type = Path.GetFileName(key);
|
||||||
Building[] buildings = Game1.locations
|
Building[] buildings = this.GetLocations(buildingInteriors: false)
|
||||||
.OfType<BuildableGameLocation>()
|
.OfType<BuildableGameLocation>()
|
||||||
.SelectMany(p => p.buildings)
|
.SelectMany(p => p.buildings)
|
||||||
.Where(p => p.buildingType.Value == type)
|
.Where(p => p.buildingType.Value == type)
|
||||||
|
@ -694,6 +730,35 @@ namespace StardewModdingAPI.Metadata
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Reload tree textures.</summary>
|
||||||
|
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||||
|
/// <param name="key">The asset key to reload.</param>
|
||||||
|
/// <returns>Returns whether any textures were reloaded.</returns>
|
||||||
|
private bool ReloadGrassTextures(LocalizedContentManager content, string key)
|
||||||
|
{
|
||||||
|
Grass[] grasses =
|
||||||
|
(
|
||||||
|
from location in this.GetLocations()
|
||||||
|
from grass in location.terrainFeatures.Values.OfType<Grass>()
|
||||||
|
let textureName = this.NormalizeAssetNameIgnoringEmpty(
|
||||||
|
this.Reflection.GetMethod(grass, "textureName").Invoke<string>()
|
||||||
|
)
|
||||||
|
where textureName == key
|
||||||
|
select grass
|
||||||
|
)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (grasses.Any())
|
||||||
|
{
|
||||||
|
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
|
||||||
|
foreach (Grass grass in grasses)
|
||||||
|
this.Reflection.GetField<Lazy<Texture2D>>(grass, "texture").SetValue(texture);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Reload the disposition data for matching NPCs.</summary>
|
/// <summary>Reload the disposition data for matching NPCs.</summary>
|
||||||
/// <param name="content">The content manager through which to reload the asset.</param>
|
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||||
/// <param name="key">The asset key to reload.</param>
|
/// <param name="key">The asset key to reload.</param>
|
||||||
|
@ -717,51 +782,57 @@ namespace StardewModdingAPI.Metadata
|
||||||
/// <summary>Reload the sprites for matching NPCs.</summary>
|
/// <summary>Reload the sprites for matching NPCs.</summary>
|
||||||
/// <param name="content">The content manager through which to reload the asset.</param>
|
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||||
/// <param name="keys">The asset keys to reload.</param>
|
/// <param name="keys">The asset keys to reload.</param>
|
||||||
/// <returns>Returns the number of reloaded assets.</returns>
|
/// <param name="propagated">The asset keys which have been propagated.</param>
|
||||||
private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys)
|
private void ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
|
||||||
{
|
{
|
||||||
// get NPCs
|
// get NPCs
|
||||||
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
|
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
|
||||||
NPC[] characters = this.GetCharacters()
|
var characters =
|
||||||
.Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)))
|
(
|
||||||
|
from npc in this.GetCharacters()
|
||||||
|
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)
|
||||||
|
where key != null && lookup.Contains(key)
|
||||||
|
select new { Npc = npc, Key = key }
|
||||||
|
)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (!characters.Any())
|
if (!characters.Any())
|
||||||
return 0;
|
return;
|
||||||
|
|
||||||
// update sprite
|
// update sprite
|
||||||
int reloaded = 0;
|
foreach (var target in characters)
|
||||||
foreach (NPC npc in characters)
|
|
||||||
{
|
{
|
||||||
this.SetSpriteTexture(npc.Sprite, content.Load<Texture2D>(npc.Sprite.textureName.Value));
|
this.SetSpriteTexture(target.Npc.Sprite, content.Load<Texture2D>(target.Key));
|
||||||
reloaded++;
|
propagated[target.Key] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return reloaded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reload the portraits for matching NPCs.</summary>
|
/// <summary>Reload the portraits for matching NPCs.</summary>
|
||||||
/// <param name="content">The content manager through which to reload the asset.</param>
|
/// <param name="content">The content manager through which to reload the asset.</param>
|
||||||
/// <param name="keys">The asset key to reload.</param>
|
/// <param name="keys">The asset key to reload.</param>
|
||||||
/// <returns>Returns the number of reloaded assets.</returns>
|
/// <param name="propagated">The asset keys which have been propagated.</param>
|
||||||
private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys)
|
private void ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
|
||||||
{
|
{
|
||||||
// get NPCs
|
// get NPCs
|
||||||
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
|
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
|
||||||
var villagers = this
|
var characters =
|
||||||
.GetCharacters()
|
(
|
||||||
.Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)))
|
from npc in this.GetCharacters()
|
||||||
|
where npc.isVillager()
|
||||||
|
|
||||||
|
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)
|
||||||
|
where key != null && lookup.Contains(key)
|
||||||
|
select new { Npc = npc, Key = key }
|
||||||
|
)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
if (!villagers.Any())
|
if (!characters.Any())
|
||||||
return 0;
|
return;
|
||||||
|
|
||||||
// update portrait
|
// update portrait
|
||||||
int reloaded = 0;
|
foreach (var target in characters)
|
||||||
foreach (NPC npc in villagers)
|
|
||||||
{
|
{
|
||||||
npc.Portrait = content.Load<Texture2D>(npc.Portrait.Name);
|
target.Npc.Portrait = content.Load<Texture2D>(target.Key);
|
||||||
reloaded++;
|
propagated[target.Key] = true;
|
||||||
}
|
}
|
||||||
return reloaded;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Reload tree textures.</summary>
|
/// <summary>Reload tree textures.</summary>
|
||||||
|
@ -771,7 +842,7 @@ namespace StardewModdingAPI.Metadata
|
||||||
/// <returns>Returns whether any textures were reloaded.</returns>
|
/// <returns>Returns whether any textures were reloaded.</returns>
|
||||||
private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
|
private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
|
||||||
{
|
{
|
||||||
Tree[] trees = Game1.locations
|
Tree[] trees = this.GetLocations()
|
||||||
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
|
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
|
||||||
.Where(tree => tree.treeType.Value == type)
|
.Where(tree => tree.treeType.Value == type)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
@ -876,7 +947,8 @@ namespace StardewModdingAPI.Metadata
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Get all locations in the game.</summary>
|
/// <summary>Get all locations in the game.</summary>
|
||||||
private IEnumerable<GameLocation> GetLocations()
|
/// <param name="buildingInteriors">Whether to also get the interior locations for constructable buildings.</param>
|
||||||
|
private IEnumerable<GameLocation> GetLocations(bool buildingInteriors = true)
|
||||||
{
|
{
|
||||||
// get available root locations
|
// get available root locations
|
||||||
IEnumerable<GameLocation> rootLocations = Game1.locations;
|
IEnumerable<GameLocation> rootLocations = Game1.locations;
|
||||||
|
@ -888,7 +960,7 @@ namespace StardewModdingAPI.Metadata
|
||||||
{
|
{
|
||||||
yield return location;
|
yield return location;
|
||||||
|
|
||||||
if (location is BuildableGameLocation buildableLocation)
|
if (buildingInteriors && location is BuildableGameLocation buildableLocation)
|
||||||
{
|
{
|
||||||
foreach (Building building in buildableLocation.buildings)
|
foreach (Building building in buildableLocation.buildings)
|
||||||
{
|
{
|
||||||
|
|
|
@ -60,6 +60,7 @@ namespace StardewModdingAPI.Metadata
|
||||||
if (paranoidMode)
|
if (paranoidMode)
|
||||||
{
|
{
|
||||||
// filesystem access
|
// filesystem access
|
||||||
|
yield return new TypeFinder(typeof(System.Console).FullName, InstructionHandleResult.DetectedConsoleAccess);
|
||||||
yield return new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
yield return new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
||||||
yield return new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
yield return new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
||||||
yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess);
|
||||||
|
|
|
@ -59,12 +59,6 @@ The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to
|
||||||
*/
|
*/
|
||||||
"LogNetworkTraffic": false,
|
"LogNetworkTraffic": false,
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod
|
|
||||||
* metadata for detected mods. This is only needed when troubleshooting some cases.
|
|
||||||
*/
|
|
||||||
"DumpMetadata": false,
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The colors to use for text written to the SMAPI console.
|
* The colors to use for text written to the SMAPI console.
|
||||||
*
|
*
|
||||||
|
|
|
@ -99,9 +99,30 @@
|
||||||
<Link>SMAPI.metadata.json</Link>
|
<Link>SMAPI.metadata.json</Link>
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</Content>
|
</Content>
|
||||||
|
<None Update="i18n\de.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="i18n\es.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="i18n\ja.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
<None Update="i18n\default.json">
|
<None Update="i18n\default.json">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
<None Update="i18n\pt.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="i18n\ru.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="i18n\tr.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="i18n\zh.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
<None Update="steam_appid.txt">
|
<None Update="steam_appid.txt">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)."
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)"
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)."
|
||||||
|
}
|
Loading…
Reference in New Issue