summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore5
-rw-r--r--build/common.targets2
-rw-r--r--docs/README.md6
-rw-r--r--docs/release-notes.md46
-rw-r--r--docs/technical/mod-package.md4
-rw-r--r--docs/technical/smapi.md16
-rw-r--r--docs/technical/web.md73
-rw-r--r--src/SMAPI.Installer/unix-launcher.sh6
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs255
-rw-r--r--src/SMAPI.Mods.ConsoleCommands/manifest.json4
-rw-r--r--src/SMAPI.Mods.SaveBackup/manifest.json4
-rw-r--r--src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs7
-rw-r--r--src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs32
-rw-r--r--src/SMAPI.Web/Controllers/IndexController.cs3
-rw-r--r--src/SMAPI.Web/Controllers/JsonValidatorController.cs64
-rw-r--r--src/SMAPI.Web/Controllers/LogParserController.cs178
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs5
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs8
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs58
-rw-r--r--src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs15
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs27
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs23
-rw-r--r--src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs3
-rw-r--r--src/SMAPI.Web/Framework/Extensions.cs14
-rw-r--r--src/SMAPI.Web/Framework/LogParsing/LogParser.cs2
-rw-r--r--src/SMAPI.Web/Framework/Storage/IStorageProvider.cs18
-rw-r--r--src/SMAPI.Web/Framework/Storage/StorageProvider.cs181
-rw-r--r--src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs23
-rw-r--r--src/SMAPI.Web/Framework/Storage/UploadResult.cs33
-rw-r--r--src/SMAPI.Web/SMAPI.Web.csproj4
-rw-r--r--src/SMAPI.Web/Startup.cs39
-rw-r--r--src/SMAPI.Web/ViewModels/IndexModel.cs7
-rw-r--r--src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs19
-rw-r--r--src/SMAPI.Web/Views/Index/Index.cshtml24
-rw-r--r--src/SMAPI.Web/Views/Index/Privacy.cshtml8
-rw-r--r--src/SMAPI.Web/Views/JsonValidator/Index.cshtml71
-rw-r--r--src/SMAPI.Web/Views/LogParser/Index.cshtml27
-rw-r--r--src/SMAPI.Web/appsettings.Development.json21
-rw-r--r--src/SMAPI.Web/appsettings.json22
-rw-r--r--src/SMAPI.Web/wwwroot/Content/css/json-validator.css6
-rw-r--r--src/SMAPI.Web/wwwroot/Content/js/json-validator.js8
-rw-r--r--src/SMAPI.Web/wwwroot/schemas/content-patcher.json45
-rw-r--r--src/SMAPI/Constants.cs2
-rw-r--r--src/SMAPI/Events/ChestInventoryChangedEventArgs.cs48
-rw-r--r--src/SMAPI/Events/IWorldEvents.cs3
-rw-r--r--src/SMAPI/Events/InventoryChangedEventArgs.cs34
-rw-r--r--src/SMAPI/Events/ItemStackChange.cs20
-rw-r--r--src/SMAPI/Framework/Content/AssetDataForImage.cs4
-rw-r--r--src/SMAPI/Framework/Content/AssetInterceptorChange.cs93
-rw-r--r--src/SMAPI/Framework/Content/ContentCache.cs5
-rw-r--r--src/SMAPI/Framework/ContentCoordinator.cs107
-rw-r--r--src/SMAPI/Framework/ContentManagers/BaseContentManager.cs38
-rw-r--r--src/SMAPI/Framework/ContentManagers/GameContentManager.cs49
-rw-r--r--src/SMAPI/Framework/ContentManagers/IContentManager.cs4
-rw-r--r--src/SMAPI/Framework/ContentManagers/ModContentManager.cs30
-rw-r--r--src/SMAPI/Framework/Events/EventManager.cs4
-rw-r--r--src/SMAPI/Framework/Events/ModWorldEvents.cs7
-rw-r--r--src/SMAPI/Framework/IModMetadata.cs4
-rw-r--r--src/SMAPI/Framework/Input/SInputState.cs3
-rw-r--r--src/SMAPI/Framework/ModHelpers/DataHelper.cs48
-rw-r--r--src/SMAPI/Framework/ModLoading/AssemblyLoader.cs5
-rw-r--r--src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs3
-rw-r--r--src/SMAPI/Framework/ModLoading/ModMetadata.cs21
-rw-r--r--src/SMAPI/Framework/Models/ModFolderExport.cs21
-rw-r--r--src/SMAPI/Framework/Models/SConfig.cs6
-rw-r--r--src/SMAPI/Framework/Reflection/ReflectedMethod.cs4
-rw-r--r--src/SMAPI/Framework/SCore.cs154
-rw-r--r--src/SMAPI/Framework/SGame.cs75
-rw-r--r--src/SMAPI/Framework/SnapshotItemListDiff.cs66
-rw-r--r--src/SMAPI/Framework/StateTracking/ChestTracker.cs101
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs143
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs28
-rw-r--r--src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs8
-rw-r--r--src/SMAPI/Framework/StateTracking/LocationTracker.cs60
-rw-r--r--src/SMAPI/Framework/StateTracking/PlayerTracker.cs32
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs13
-rw-r--r--src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs15
-rw-r--r--src/SMAPI/Metadata/CoreAssetPropagator.cs144
-rw-r--r--src/SMAPI/Metadata/InstructionMetadata.cs1
-rw-r--r--src/SMAPI/SMAPI.config.json6
-rw-r--r--src/SMAPI/SMAPI.csproj21
-rw-r--r--src/SMAPI/i18n/es.json3
-rw-r--r--src/SMAPI/i18n/ja.json3
-rw-r--r--src/SMAPI/i18n/pt.json3
-rw-r--r--src/SMAPI/i18n/zh.json6
85 files changed, 1848 insertions, 1013 deletions
diff --git a/.gitignore b/.gitignore
index 65695211..5450a2f5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,9 @@ _ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
+# Rider
+.idea/
+
# NuGet packages
*.nupkg
**/packages/*
@@ -31,4 +34,4 @@ appsettings.Development.json
src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json
# Azure generated files
-src/SMAPI.Web/Properties/PublishProfiles/smapi-web-release - Web Deploy.pubxml
+src/SMAPI.Web/Properties/PublishProfiles/*.pubxml
diff --git a/build/common.targets b/build/common.targets
index e738bab0..df2d4861 100644
--- a/build/common.targets
+++ b/build/common.targets
@@ -4,7 +4,7 @@
<!--set properties -->
<PropertyGroup>
- <Version>3.0.1</Version>
+ <Version>3.1.0</Version>
<Product>SMAPI</Product>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>
diff --git a/docs/README.md b/docs/README.md
index 386259a9..3a570f48 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -68,9 +68,9 @@ French | ❑ not translated
German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
Hungarian | ❑ not translated
Italian | ❑ not translated
-Japanese | ❑ not translated
+Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json)
Korean | ❑ not translated
-Portuguese | ❑ not translated
+Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.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)
diff --git a/docs/release-notes.md b/docs/release-notes.md
index 1c106542..ed6f9013 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -1,8 +1,52 @@
&larr; [README](README.md)
# 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
-Released 02 December 2019 for Stardew Valley 1.4.0.1.
+Released 02 December 2019 for Stardew Valley 1.4 or later.
* For players:
* Updated for Stardew Valley 1.4.0.1.
diff --git a/docs/technical/mod-package.md b/docs/technical/mod-package.md
index a33480ad..5b971f96 100644
--- a/docs/technical/mod-package.md
+++ b/docs/technical/mod-package.md
@@ -40,7 +40,7 @@ property | description
`$(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).
-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
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>
```
- 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.**
_You'll need to do this for each project that uses the package._
diff --git a/docs/technical/smapi.md b/docs/technical/smapi.md
index 96f7dff5..d565aeb4 100644
--- a/docs/technical/smapi.md
+++ b/docs/technical/smapi.md
@@ -71,14 +71,14 @@ flag | purpose
### Compiling from source
Using an official SMAPI release is recommended for most users.
-SMAPI uses some C# 7 code, so you'll need at least
-[Visual Studio 2017](https://www.visualstudio.com/vs/community/) on Windows,
-[MonoDevelop 7.0](https://www.monodevelop.com/) on Linux,
-[Visual Studio 2017 for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent
-IDE to compile it. It uses build configuration derived from the
-[crossplatform mod config](https://github.com/Pathoschild/Stardew.ModBuildConfig#readme) to detect
-your current OS automatically and load the correct references. Compile output will be placed in a
-`bin` folder at the root of the git repository.
+SMAPI often uses the latest C# syntax. You may need the latest version of
+[Visual Studio](https://www.visualstudio.com/vs/community/) on Windows,
+[MonoDevelop](https://www.monodevelop.com/) on Linux,
+[Visual Studio for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent IDE
+to compile it. It uses build configuration derived from the
+[crossplatform mod config](https://smapi.io/package/readme) to detect your current OS automatically
+and load the correct references. Compile output will be placed in a `bin` folder at the root of the
+git repository.
### Debugging a local build
Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting
diff --git a/docs/technical/web.md b/docs/technical/web.md
index 97e0704a..67e86c8b 100644
--- a/docs/technical/web.md
+++ b/docs/technical/web.md
@@ -10,17 +10,21 @@ and update check API.
* [Short URLs](#short-urls)
* [For SMAPI developers](#for-smapi-developers)
* [Local development](#local-development)
- * [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
+ * [Production environment](#production-environment)
## Log parser
-The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are
-persisted in a compressed form to Pastebin. The log parser lives at https://smapi.io/log.
+The log parser at https://smapi.io/log provides a web UI for uploading, parsing, and sharing SMAPI
+logs.
+
+The logs are saved in a compressed form to Amazon Blob storage for 30 days.
## JSON validator
### Overview
-The JSON validator provides a web UI for uploading and sharing JSON files, and validating them as
-plain JSON or against a predefined format like `manifest.json` or Content Patcher's `content.json`.
-The JSON validator lives at https://smapi.io/json.
+The JSON validator at https://smapi.io/json provides a web UI for uploading and sharing JSON files,
+and validating them as plain JSON or against a predefined format like `manifest.json` or Content
+Patcher's `content.json`.
+
+The logs are saved in a compressed form to Amazon Blob storage for 30 days.
### Schema file format
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
your machine, with no external dependencies aside from the actual mod sites.
-Initial setup:
-
-1. [Install MongoDB](https://docs.mongodb.com/manual/administration/install-community/) and add its
- `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.
- <small>(Local URLs will use HTTP instead of HTTPS.)</small>
+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
+ on disk.
+2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
### Production environment
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
-the official live environment).
+access.
+
+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:
-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:
+1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas))
+ 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
+ ------------------------------- | -----------------
+ `ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2.
+ `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.
+ `ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client).
+ `MongoDB:ConnectionString` | The connection string for the MongoDB instance.
+ `MongoDB:Database` | The MongoDB database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
+
+ Optional settings:
property name | description
------------------------------- | -----------------
- `LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API.
- `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.
- `ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info.
- `ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info.
- `MongoDB:Host` | The hostname for the MongoDB instance.
- `MongoDB:Username` | The login username for the MongoDB instance.
- `MongoDB:Password` | The login password for the MongoDB instance.
- `MongoDB:Database` | The database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
+ `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:
-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.)
diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh
index bebba9fe..b72eed22 100644
--- a/src/SMAPI.Installer/unix-launcher.sh
+++ b/src/SMAPI.Installer/unix-launcher.sh
@@ -61,8 +61,8 @@ else
COMMAND="type"
fi
- # select terminal (prefer $TERMINAL for overrides and testing, then 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
+ # select terminal (prefer xterm for best compatibility, then known supported terminals)
+ 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
# Find the true shell behind x-terminal-emulator
if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then
@@ -108,7 +108,7 @@ else
alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*'
fi
;;
- xterm|xfce4-terminal|gnome-terminal|terminal|termite)
+ xterm|xfce4-terminal|gnome-terminal|terminal|termite|mate-terminal)
$LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'"
;;
konsole)
diff --git a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
index 0648aa2b..08dd8eed 100644
--- a/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
+++ b/src/SMAPI.Mods.ConsoleCommands/Framework/ItemRepository.cs
@@ -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 + 3, () => new Wand());
+ // clothing
+ foreach (int id in Game1.clothingInformation.Keys)
+ yield return this.TryCreate(ItemType.Clothing, id, () => new Clothing(id));
+
// wallpapers
for (int id = 0; id < 112; id++)
yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory });
// 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 });
// equipment
@@ -59,11 +63,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
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
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
@@ -87,147 +86,141 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
foreach (int id in Game1.bigCraftablesInformation.Keys)
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
foreach (int id in Game1.objectInformation.Keys)
{
- if (id == 79)
- continue; // secret note handled above
- if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
- continue; // handled separated
-
- // spawn main item
- SObject item;
- {
- SearchableItem main = this.TryCreate(ItemType.Object, id, () => id == 812
- ? new ColoredObject(id, 1, Color.White)
- : new SObject(id, 1)
- );
- yield return main;
- item = main?.Item as SObject;
- }
- if (item == null)
- continue;
+ string[] fields = Game1.objectInformation[id]?.Split('/');
- // fruit products
- if (item.Category == SObject.FruitsCategory)
+ // secret notes
+ if (id == 79)
{
- // wine
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () =>
+ foreach (int secretNoteId in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
{
- SObject wine = new SObject(348, 1)
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, () =>
{
- Name = $"{item.Name} Wine",
- Price = item.Price * 3
- };
- wine.preserve.Value = SObject.PreserveType.Wine;
- wine.preservedParentSheetIndex.Value = item.ParentSheetIndex;
- return wine;
- });
-
- // jelly
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () =>
- {
- SObject jelly = new SObject(344, 1)
- {
- Name = $"{item.Name} Jelly",
- Price = 50 + item.Price * 2
- };
- jelly.preserve.Value = SObject.PreserveType.Jelly;
- jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex;
- return jelly;
- });
+ SObject note = new SObject(79, 1);
+ note.name = $"{note.name} #{secretNoteId}";
+ return note;
+ });
+ }
}
- // vegetable products
- else if (item.Category == SObject.VegetableCategory)
- {
- // juice
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () =>
- {
- SObject juice = new SObject(350, 1)
- {
- Name = $"{item.Name} Juice",
- Price = (int)(item.Price * 2.25d)
- };
- juice.preserve.Value = SObject.PreserveType.Juice;
- juice.preservedParentSheetIndex.Value = item.ParentSheetIndex;
- return juice;
- });
-
- // pickled
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
- {
- SObject pickled = new SObject(342, 1)
- {
- Name = $"Pickled {item.Name}",
- Price = 50 + item.Price * 2
- };
- pickled.preserve.Value = SObject.PreserveType.Pickle;
- pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex;
- return pickled;
- });
- }
+ // 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));
- // flower honey
- else if (item.Category == SObject.flowersCategory)
+ // item
+ else
{
- yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
+ // spawn main item
+ SObject item = null;
+ yield return this.TryCreate(ItemType.Object, id, () =>
{
- SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
- {
- Name = $"{item.Name} Honey",
- preservedParentSheetIndex = { item.ParentSheetIndex }
- };
- honey.Price += item.Price * 2;
- return honey;
+ return item = (id == 812 // roe
+ ? new ColoredObject(id, 1, Color.White)
+ : new SObject(id, 1)
+ );
});
- }
+ if (item == null)
+ continue;
- // roe and aged roe (derived from FishPond.GetFishProduce)
- else if (id == 812)
- {
- foreach (var pair in Game1.objectInformation)
+ // flavored items
+ switch (item.Category)
{
- // get input
- SObject input = new SObject(pair.Key, 1);
- if (input.Category != SObject.FishCategory)
- continue;
- Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange;
-
- // yield roe
- SObject roe = new ColoredObject(812, 1, color)
- {
- name = $"{input.Name} Roe",
- preserve = { Value = SObject.PreserveType.Roe },
- preservedParentSheetIndex = { Value = input.ParentSheetIndex }
- };
- roe.Price += input.Price / 2;
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, roe);
-
- // aged roe
- if (pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
- {
- ColoredObject agedRoe = new ColoredObject(447, 1, color)
+ // fruit products
+ case SObject.FruitsCategory:
+ // wine
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () => new SObject(348, 1)
+ {
+ Name = $"{item.Name} Wine",
+ Price = item.Price * 3,
+ preserve = { SObject.PreserveType.Wine },
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ });
+
+ // jelly
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () => new SObject(344, 1)
+ {
+ Name = $"{item.Name} Jelly",
+ Price = 50 + item.Price * 2,
+ preserve = { SObject.PreserveType.Jelly },
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ });
+ break;
+
+ // vegetable products
+ case SObject.VegetableCategory:
+ // juice
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () => new SObject(350, 1)
+ {
+ Name = $"{item.Name} Juice",
+ Price = (int)(item.Price * 2.25d),
+ preserve = { SObject.PreserveType.Juice },
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ });
+
+ // pickled
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => new SObject(342, 1)
+ {
+ Name = $"Pickled {item.Name}",
+ Price = 50 + item.Price * 2,
+ preserve = { SObject.PreserveType.Pickle },
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ });
+ break;
+
+ // flower honey
+ case SObject.flowersCategory:
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
+ {
+ SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
+ {
+ Name = $"{item.Name} Honey",
+ preservedParentSheetIndex = { item.ParentSheetIndex }
+ };
+ honey.Price += item.Price * 2;
+ return honey;
+ });
+ break;
+
+ // roe and aged roe (derived from FishPond.GetFishProduce)
+ case SObject.sellAtFishShopCategory when id == 812:
+ foreach (var pair in Game1.objectInformation)
{
- name = $"Aged {input.Name} Roe",
- Category = -27,
- preserve = { Value = SObject.PreserveType.AgedRoe },
- preservedParentSheetIndex = { Value = input.ParentSheetIndex },
- Price = roe.Price * 2
- };
- yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, agedRoe);
- }
+ // get input
+ SObject input = this.TryCreate(ItemType.Object, -1, () => new SObject(pair.Key, 1))?.Item as SObject;
+ if (input == null || input.Category != SObject.FishCategory)
+ continue;
+ Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange;
+
+ // yield roe
+ SObject roe = null;
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () =>
+ {
+ roe = new ColoredObject(812, 1, color)
+ {
+ name = $"{input.Name} Roe",
+ preserve = { Value = SObject.PreserveType.Roe },
+ preservedParentSheetIndex = { Value = input.ParentSheetIndex }
+ };
+ roe.Price += input.Price / 2;
+ return roe;
+ });
+
+ // aged roe
+ if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
+ {
+ yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () => new ColoredObject(447, 1, color)
+ {
+ name = $"Aged {input.Name} Roe",
+ Category = -27,
+ preserve = { Value = SObject.PreserveType.AgedRoe },
+ preservedParentSheetIndex = { Value = input.ParentSheetIndex },
+ Price = roe.Price * 2
+ });
+ }
+ }
+ break;
}
}
}
diff --git a/src/SMAPI.Mods.ConsoleCommands/manifest.json b/src/SMAPI.Mods.ConsoleCommands/manifest.json
index badea825..1e12e13e 100644
--- a/src/SMAPI.Mods.ConsoleCommands/manifest.json
+++ b/src/SMAPI.Mods.ConsoleCommands/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Console Commands",
"Author": "SMAPI",
- "Version": "3.0.1",
+ "Version": "3.1.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll",
- "MinimumApiVersion": "3.0.1"
+ "MinimumApiVersion": "3.1.0"
}
diff --git a/src/SMAPI.Mods.SaveBackup/manifest.json b/src/SMAPI.Mods.SaveBackup/manifest.json
index 252c359f..55af8f35 100644
--- a/src/SMAPI.Mods.SaveBackup/manifest.json
+++ b/src/SMAPI.Mods.SaveBackup/manifest.json
@@ -1,9 +1,9 @@
{
"Name": "Save Backup",
"Author": "SMAPI",
- "Version": "3.0.1",
+ "Version": "3.1.0",
"Description": "Automatically backs up all your saves once per day into its folder.",
"UniqueID": "SMAPI.SaveBackup",
"EntryDll": "SaveBackup.dll",
- "MinimumApiVersion": "3.0.1"
+ "MinimumApiVersion": "3.1.0"
}
diff --git a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs
index e67616d0..925e0b5c 100644
--- a/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs
+++ b/src/SMAPI.Toolkit/Framework/ModData/ModWarning.cs
@@ -27,10 +27,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
/// <summary>The mod has no update keys set.</summary>
NoUpdateKeys = 32,
+ /// <summary>Uses .NET APIs for reading and writing to the console.</summary>
+ AccessesConsole = 64,
+
/// <summary>Uses .NET APIs for filesystem access.</summary>
- AccessesFilesystem = 64,
+ AccessesFilesystem = 128,
/// <summary>Uses .NET APIs for shell or process access.</summary>
- AccessesShell = 128
+ AccessesShell = 256
}
}
diff --git a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs
index 6dce5da5..2a01fe4b 100644
--- a/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs
+++ b/src/SMAPI.Toolkit/Utilities/EnvironmentUtility.cs
@@ -105,23 +105,27 @@ namespace StardewModdingAPI.Toolkit.Utilities
/// </remarks>
private static bool IsRunningAndroid()
{
- using (Process process = new Process())
+ using Process process = new Process
{
- process.StartInfo.FileName = "getprop";
- process.StartInfo.Arguments = "ro.build.user";
- process.StartInfo.RedirectStandardOutput = true;
- process.StartInfo.UseShellExecute = false;
- process.StartInfo.CreateNoWindow = true;
- try
+ StartInfo =
{
- process.Start();
- string output = process.StandardOutput.ReadToEnd();
- return !string.IsNullOrEmpty(output);
- }
- catch
- {
- return false;
+ FileName = "getprop",
+ Arguments = "ro.build.user",
+ RedirectStandardOutput = true,
+ UseShellExecute = false,
+ CreateNoWindow = true
}
+ };
+
+ try
+ {
+ process.Start();
+ string output = process.StandardOutput.ReadToEnd();
+ return !string.IsNullOrWhiteSpace(output);
+ }
+ catch
+ {
+ return false;
}
}
diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs
index 4e3602d5..080285ab 100644
--- a/src/SMAPI.Web/Controllers/IndexController.cs
+++ b/src/SMAPI.Web/Controllers/IndexController.cs
@@ -16,7 +16,6 @@ namespace StardewModdingAPI.Web.Controllers
{
/// <summary>Provides an info/download page about SMAPI.</summary>
[Route("")]
- [Route("install")]
internal class IndexController : Controller
{
/*********
@@ -72,7 +71,7 @@ namespace StardewModdingAPI.Web.Controllers
: null;
// 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);
}
diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
index 40599abc..2ade3e3d 100644
--- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs
+++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs
@@ -9,8 +9,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema;
using StardewModdingAPI.Web.Framework;
-using StardewModdingAPI.Web.Framework.Clients.Pastebin;
-using StardewModdingAPI.Web.Framework.Compression;
+using StardewModdingAPI.Web.Framework.Storage;
using StardewModdingAPI.Web.ViewModels.JsonValidator;
namespace StardewModdingAPI.Web.Controllers
@@ -21,11 +20,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Fields
*********/
- /// <summary>The underlying Pastebin client.</summary>
- private readonly IPastebinClient Pastebin;
-
- /// <summary>The underlying text compression helper.</summary>
- private readonly IGzipHelper GzipHelper;
+ /// <summary>Provides access to raw data storage.</summary>
+ private readonly IStorageProvider Storage;
/// <summary>The supported JSON schemas (names indexed by ID).</summary>
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
@@ -49,20 +45,18 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
- /// <param name="pastebin">The Pastebin API client.</param>
- /// <param name="gzipHelper">The underlying text compression helper.</param>
- public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper)
+ /// <param name="storage">Provides access to raw data storage.</param>
+ public JsonValidatorController(IStorageProvider storage)
{
- this.Pastebin = pastebin;
- this.GzipHelper = gzipHelper;
+ this.Storage = storage;
}
/***
** Web UI
***/
/// <summary>Render the schema validator UI.</summary>
- /// <param name="schemaName">The schema name with which to validate the JSON.</param>
- /// <param name="id">The paste ID.</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 stored file ID.</param>
[HttpGet]
[Route("json")]
[Route("json/{schemaName}")]
@@ -76,16 +70,20 @@ namespace StardewModdingAPI.Web.Controllers
return this.View("Index", result);
// fetch raw JSON
- PasteInfo paste = await this.GetAsync(id);
- if (string.IsNullOrWhiteSpace(paste.Content))
+ StoredFileInfo file = await this.Storage.GetAsync(id);
+ if (string.IsNullOrWhiteSpace(file.Content))
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
JToken parsed;
try
{
- parsed = JToken.Parse(paste.Content, new JsonLoadSettings
+ parsed = JToken.Parse(file.Content, new JsonLoadSettings
{
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
CommentHandling = CommentHandling.Load
@@ -97,7 +95,7 @@ namespace StardewModdingAPI.Web.Controllers
}
// 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
if (schemaName == "none")
@@ -132,23 +130,20 @@ namespace StardewModdingAPI.Web.Controllers
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
{
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
string schemaName = this.NormalizeSchemaName(request.SchemaName);
- // get raw log text
+ // get raw text
string input = request.Content;
if (string.IsNullOrWhiteSpace(input))
- return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty."));
-
- // upload log
- input = this.GzipHelper.CompressString(input);
- SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input);
+ return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
- // handle errors
- if (!result.Success)
- return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
+ // upload file
+ UploadResult result = await this.Storage.SaveAsync(input);
+ if (!result.Succeeded)
+ return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
// redirect to view
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
@@ -158,13 +153,12 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** Private methods
*********/
- /// <summary>Fetch raw text from Pastebin.</summary>
- /// <param name="id">The Pastebin paste ID.</param>
- private async Task<PasteInfo> GetAsync(string id)
+ /// <summary>Build a JSON validator model.</summary>
+ /// <param name="pasteID">The stored file ID.</param>
+ /// <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);
- response.Content = this.GzipHelper.DecompressString(response.Content);
- return response;
+ return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats);
}
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>
diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs
index 318b34d0..97c419d9 100644
--- a/src/SMAPI.Web/Controllers/LogParserController.cs
+++ b/src/SMAPI.Web/Controllers/LogParserController.cs
@@ -1,22 +1,12 @@
using System;
-using System.IO;
using System.Linq;
-using System.Text;
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.Extensions.Options;
using StardewModdingAPI.Toolkit.Utilities;
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.Models;
+using StardewModdingAPI.Web.Framework.Storage;
using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers
@@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** 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>Provides access to raw data storage.</summary>
+ private readonly IStorageProvider Storage;
/*********
@@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor
***/
/// <summary>Construct an instance.</summary>
- /// <param name="clientsConfig">The API client settings.</param>
- /// <param name="pastebin">The Pastebin API client.</param>
- /// <param name="gzipHelper">The underlying text compression helper.</param>
- public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
+ /// <param name="storage">Provides access to raw data storage.</param>
+ public LogParserController(IStorageProvider storage)
{
- this.ClientsConfig = clientsConfig.Value;
- this.Pastebin = pastebin;
- this.GzipHelper = gzipHelper;
+ this.Storage = storage;
}
/***
** Web UI
***/
/// <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>
[HttpGet]
[Route("log")]
@@ -70,12 +50,12 @@ namespace StardewModdingAPI.Web.Controllers
return this.View("Index", this.GetModel(id));
// log page
- PasteInfo paste = await this.GetAsync(id);
- ParsedLog log = paste.Success
- ? new LogParser().Parse(paste.Content)
- : new ParsedLog { IsValid = false, Error = paste.Error };
+ StoredFileInfo file = await this.Storage.GetAsync(id);
+ ParsedLog log = file.Success
+ ? new LogParser().Parse(file.Content)
+ : 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."));
// upload log
- input = this.GzipHelper.CompressString(input);
- var uploadResult = await this.TrySaveLog(input);
+ UploadResult uploadResult = await this.Storage.SaveAsync(input);
if (!uploadResult.Succeeded)
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
@@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers
/*********
** 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>
- /// <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="uploadWarning">A non-blocking warning 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;
}
}
-
- /// <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;
- }
- }
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
index a635abe3..431fed7b 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/IPastebinClient.cs
@@ -9,10 +9,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>Fetch a saved paste.</summary>
/// <param name="id">The paste ID.</param>
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);
}
}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
index bb2de356..813ea115 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{
/// <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>
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; }
}
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
index d695aab6..1be00be7 100644
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
+++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PastebinClient.cs
@@ -1,7 +1,5 @@
using System;
-using System.Linq;
using System.Net;
-using System.Net.Http;
using System.Threading.Tasks;
using Pathoschild.Http.Client;
@@ -16,12 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>The underlying HTTP client.</summary>
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
@@ -29,13 +21,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>Construct an instance.</summary>
/// <param name="baseUrl">The base URL for the Pastebin API.</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>
- /// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param>
- public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey)
+ public PastebinClient(string baseUrl, string userAgent)
{
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
- this.UserKey = userKey;
- this.DevKey = devKey;
}
/// <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>
public void Dispose()
{
diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs
deleted file mode 100644
index 89dab697..00000000
--- a/src/SMAPI.Web/Framework/Clients/Pastebin/SavePasteResult.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
index 7119ef03..878130bf 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs
@@ -14,19 +14,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/****
- ** Amazon Web Services
+ ** Azure
****/
- /// <summary>The access key for AWS authentication.</summary>
- public string AmazonAccessKey { get; set; }
+ /// <summary>The connection string for the Azure Blob storage account.</summary>
+ public string AzureBlobConnectionString { get; set; }
- /// <summary>The secret key for AWS authentication.</summary>
- public string AmazonSecretKey { get; set; }
+ /// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary>
+ public string AzureBlobTempContainer { get; set; }
- /// <summary>The AWS region endpoint (like 'us-east-1').</summary>
- public string AmazonRegion { get; set; }
-
- /// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
- public string AmazonLogBucket { get; set; }
+ /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary>
+ public int AzureBlobTempExpiryDays { 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>
public string GitHubPassword { get; set; }
+
/****
** 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>
public string ModDropModPageUrl { get; set; }
+
/****
** Nexus Mods
****/
@@ -85,17 +84,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The Nexus API authentication key.</summary>
public string NexusApiKey { get; set; }
+
/****
** Pastebin
****/
/// <summary>The base URL for the Pastebin API.</summary>
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; }
-
}
}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs
index 3c508300..c7b6cb00 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/MongoDbConfig.cs
@@ -1,5 +1,3 @@
-using System;
-
namespace StardewModdingAPI.Web.Framework.ConfigModels
{
/// <summary>The config settings for mod compatibility list.</summary>
@@ -8,14 +6,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/*********
** Accessors
*********/
- /// <summary>The MongoDB hostname.</summary>
- public string Host { 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 MongoDB connection string.</summary>
+ public string ConnectionString { get; set; }
/// <summary>The database name.</summary>
public string Database { get; set; }
@@ -24,15 +16,10 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/*********
** Public method
*********/
- /// <summary>Get the MongoDB connection string.</summary>
- public string GetConnectionString()
+ /// <summary>Get whether a MongoDB instance is configured.</summary>
+ public bool IsConfigured()
{
- bool isLocal = this.Host == "localhost";
- 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";
+ return !string.IsNullOrWhiteSpace(this.ConnectionString);
}
}
}
diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
index d379c423..43969f51 100644
--- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
+++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs
@@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>A short sentence shown under the beta download button, if any.</summary>
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; }
}
}
diff --git a/src/SMAPI.Web/Framework/Extensions.cs b/src/SMAPI.Web/Framework/Extensions.cs
index d7707924..e0da1424 100644
--- a/src/SMAPI.Web/Framework/Extensions.cs
+++ b/src/SMAPI.Web/Framework/Extensions.cs
@@ -1,4 +1,6 @@
+using System;
using JetBrains.Annotations;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
@@ -12,8 +14,9 @@ namespace StardewModdingAPI.Web.Framework
/// <param name="action">The name of the action method.</param>
/// <param name="controller">The name of the controller.</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>
- 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);
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
}
- 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;
}
}
}
diff --git a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
index 66a3687f..1210f708 100644
--- a/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
+++ b/src/SMAPI.Web/Framework/LogParsing/LogParser.cs
@@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
- private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + 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>
private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
new file mode 100644
index 00000000..96a34fbb
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs
@@ -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);
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
new file mode 100644
index 00000000..35538443
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs
@@ -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");
+ }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs
new file mode 100644
index 00000000..30676c88
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs
@@ -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; }
+ }
+}
diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs
new file mode 100644
index 00000000..483c1769
--- /dev/null
+++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/SMAPI.Web/SMAPI.Web.csproj b/src/SMAPI.Web/SMAPI.Web.csproj
index 2c56fa75..504254cd 100644
--- a/src/SMAPI.Web/SMAPI.Web.csproj
+++ b/src/SMAPI.Web/SMAPI.Web.csproj
@@ -12,8 +12,9 @@
</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.MemoryStorage" Version="1.6.3" />
<PackageReference Include="Hangfire.Mongo" Version="0.6.5" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
<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.Rewrite" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
+ <PackageReference Include="Mongo2Go" Version="2.2.12" />
<PackageReference Include="MongoDB.Driver" Version="2.9.3" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
<PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />
diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs
index 53823771..29086472 100644
--- a/src/SMAPI.Web/Startup.cs
+++ b/src/SMAPI.Web/Startup.cs
@@ -1,5 +1,7 @@
+using System;
using System.Collections.Generic;
using Hangfire;
+using Hangfire.MemoryStorage;
using Hangfire.Mongo;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -7,6 +9,8 @@ using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Mongo2Go;
using MongoDB.Bson.Serialization;
using MongoDB.Driver;
using Newtonsoft.Json;
@@ -24,6 +28,7 @@ using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RewriteRules;
+using StardewModdingAPI.Web.Framework.Storage;
namespace StardewModdingAPI.Web
{
@@ -87,10 +92,20 @@ namespace StardewModdingAPI.Web
}
// 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 =>
{
+ // get connection string
+ string connectionString = mongoConfig.IsConfigured()
+ ? mongoConfig.ConnectionString
+ : serv.GetRequiredService<MongoDbRunner>().ConnectionString;
+
+ // get client
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<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
@@ -102,12 +117,18 @@ namespace StardewModdingAPI.Web
config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer()
- .UseRecommendedSerializerSettings()
- .UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
+ .UseRecommendedSerializerSettings();
+
+ if (mongoConfig.IsConfigured())
+ {
+ config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
{
MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop),
CheckConnection = false // error on startup takes down entire process
});
+ }
+ else
+ config.UseMemoryStorage();
});
// init API clients
@@ -151,14 +172,18 @@ namespace StardewModdingAPI.Web
services.AddSingleton<IPastebinClient>(new PastebinClient(
baseUrl: api.PastebinBaseUrl,
- userAgent: userAgent,
- userKey: api.PastebinUserKey,
- devKey: api.PastebinDevKey
+ userAgent: userAgent
));
}
// 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>
diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs
index 82c4e06f..450fdc0e 100644
--- a/src/SMAPI.Web/ViewModels/IndexModel.cs
+++ b/src/SMAPI.Web/ViewModels/IndexModel.cs
@@ -15,6 +15,9 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>A short sentence shown under the beta download button, if any.</summary>
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
@@ -26,11 +29,13 @@ namespace StardewModdingAPI.Web.ViewModels
/// <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="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.BetaVersion = betaVersion;
this.BetaBlurb = betaBlurb;
+ this.SupporterList = supporterList;
}
}
}
diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
index 5b18331f..c0dd7184 100644
--- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
+++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs
@@ -1,3 +1,4 @@
+using System;
using System.Collections.Generic;
using System.Linq;
@@ -24,7 +25,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/// <summary>The schema validation errors, if any.</summary>
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; }
/// <summary>An error which occurred while parsing the JSON.</summary>
@@ -41,7 +48,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
public JsonValidatorModel() { }
/// <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="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
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>
/// <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.Expiry = expiry;
+ this.UploadWarning = uploadWarning;
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>
public JsonValidatorModel SetUploadError(string error)
{
diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml
index ec9cfafe..5d91dc84 100644
--- a/src/SMAPI.Web/Views/Index/Index.cshtml
+++ b/src/SMAPI.Web/Views/Index/Index.cshtml
@@ -1,3 +1,4 @@
+@using Markdig
@using Microsoft.Extensions.Options
@using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.Framework.ConfigModels
@@ -94,29 +95,22 @@ else
</li>
<li>
<a href="https://ko-fi.com/pathoschild" class="donate-button">
- <img src="Content/images/ko-fi.png"/> Buy me a coffee
+ <img src="Content/images/ko-fi.png" /> Buy me a coffee
</a>
</li>
<li>
<a href="https://www.paypal.me/pathoschild" class="donate-button">
- <img src="Content/images/paypal.png"/> Donate via PayPal
+ <img src="Content/images/paypal.png" /> Donate via PayPal
</a>
</li>
</ul>
-<p>
- Special thanks to
- <a href="https://www.nexusmods.com/users/65566526?tab=user+files">bwdy</a>,
- hawkfalcon,
- <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>
+@if (!string.IsNullOrWhiteSpace(Model.SupporterList))
+{
+ @Html.Raw(Markdig.Markdown.ToHtml(
+ $"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!"
+ ))
+}
<h2 id="modcreators">For mod creators</h2>
<ul>
diff --git a/src/SMAPI.Web/Views/Index/Privacy.cshtml b/src/SMAPI.Web/Views/Index/Privacy.cshtml
index 7327de3d..fd78f908 100644
--- a/src/SMAPI.Web/Views/Index/Privacy.cshtml
+++ b/src/SMAPI.Web/Views/Index/Privacy.cshtml
@@ -22,10 +22,10 @@
<h2>Data collected and transmitted</h2>
<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>
-<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>
<ol>
@@ -34,8 +34,8 @@
<li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li>
</ol>
-<h3>Log parser</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>
+<h3>Log parser and JSON validator</h3>
+<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>
<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>
diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
index de6b06a2..a00c8387 100644
--- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
+++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml
@@ -1,13 +1,15 @@
+@using Humanizer
@using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.ViewModels.JsonValidator
@model JsonValidatorModel
@{
// 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 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
ViewData["Title"] = "JSON validator";
@@ -26,17 +28,17 @@
{
<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" />
<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/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="~/Content/js/json-validator.js"></script>
+ <script src="~/Content/js/json-validator.js?r=20191204"></script>
<script>
$(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>
}
@@ -59,7 +61,7 @@ else if (Model.ParseError != null)
<small v-pre>Error details: @Model.ParseError</small>
</div>
}
-else if (Model.PasteID != null)
+else if (!isEditView && Model.PasteID != null)
{
<div class="banner success">
<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>
}
+@* 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 *@
-@if (Model.Content == null)
+@if (isEditView)
{
<h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
@@ -84,7 +98,7 @@ else if (Model.PasteID != null)
</li>
<li>
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>
Click this button:<br />
@@ -95,26 +109,23 @@ else if (Model.PasteID != null)
}
@* validation results *@
-@if (Model.Content != null)
+@if (!isEditView)
{
<div id="output">
@if (Model.UploadError == null)
{
- <div>
- 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>
- </div>
-
- <h2>Validation errors</h2>
- @if (Model.FormatUrl != null)
- {
- <p>See <a href="@Model.FormatUrl">format documentation</a>.</p>
- }
+ <h2>Validation</h2>
+ <p>
+ @(Model.Errors.Any() ? "Oops, found some issues with your JSON." : "No errors found!")
+ @if (!isValidSchema)
+ {
+ <text>(You have no schema selected, so only the basic JSON syntax was checked.)</text>
+ }
+ else if (Model.FormatUrl != null)
+ {
+ <text>See <a href="@Model.FormatUrl">format documentation</a> for more info.</text>
+ }
+ </p>
@if (Model.Errors.Any())
{
@@ -135,13 +146,17 @@ else if (Model.PasteID != null)
}
</table>
}
- else
- {
- <p>No errors found.</p>
- }
}
<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>
@if (isValidSchema)
diff --git a/src/SMAPI.Web/Views/LogParser/Index.cshtml b/src/SMAPI.Web/Views/LogParser/Index.cshtml
index 439167bc..87c7f918 100644
--- a/src/SMAPI.Web/Views/LogParser/Index.cshtml
+++ b/src/SMAPI.Web/Views/LogParser/Index.cshtml
@@ -13,6 +13,8 @@
.Cast<LogLevel>()
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
+
+ string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
}
@section Head {
@@ -50,7 +52,7 @@ else if (Model.ParseError != null)
{
<div class="banner error" v-pre>
<strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br />
- Share this URL when asking for help: <code>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 />
<br />
<small v-pre>Error details: @Model.ParseError</small>
@@ -59,7 +61,7 @@ else if (Model.ParseError != null)
else if (Model.ParsedLog?.IsValid == true)
{
<div class="banner success" v-pre>
- <strong>Share this link to let someone else see the log:</strong> <code>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>.)
</div>
}
@@ -67,12 +69,16 @@ else if (Model.ParsedLog?.IsValid == true)
@* save warnings *@
@if (Model.UploadWarning != null || Model.Expiry != null)
{
+ @if (Model.UploadWarning != null)
+ {
+ <text>⚠️ @Model.UploadWarning<br /></text>
+ }
+
<div class="save-metadata" v-pre>
@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>
}
@@ -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
<tr class="mod @levelStr @sectionStartClass"
- @if (message.IsStartOfSection)
- {
- <text>v-on:click="toggleSection('@message.Section')"</text>
- }
+ @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
<td v-pre>@message.Time</td>
<td v-pre>@message.Level.ToString().ToUpper()</td>
@@ -307,8 +310,12 @@ else if (Model.ParsedLog?.IsValid == true)
@if (message.IsStartOfSection)
{
<span class="section-toggle-message">
- <template v-if="sectionsAllow('@message.Section')">This section is shown. Click here to hide it.</template>
- <template v-else>This section is hidden. Click here to show it.</template>
+ <template v-if="sectionsAllow('@message.Section')">
+ This section is shown. Click here to hide it.
+ </template>
+ <template v-else>
+ This section is hidden. Click here to show it.
+ </template>
</span>
}
</td>
diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json
index 6b32f4ab..54460c46 100644
--- a/src/SMAPI.Web/appsettings.Development.json
+++ b/src/SMAPI.Web/appsettings.Development.json
@@ -8,28 +8,21 @@
*/
{
- "Site": {
- "BetaEnabled": false,
- "BetaBlurb": null
- },
-
"ApiClients": {
- "AmazonAccessKey": null,
- "AmazonSecretKey": null,
+ "AzureBlobConnectionString": null,
"GitHubUsername": null,
"GitHubPassword": null,
- "NexusApiKey": null,
-
- "PastebinUserKey": null,
- "PastebinDevKey": null
+ "NexusApiKey": null
},
"MongoDB": {
- "Host": "localhost",
- "Username": null,
- "Password": null,
+ "ConnectionString": null,
"Database": "smapi-edge"
+ },
+
+ "BackgroundServices": {
+ "Enabled": true
}
}
diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json
index f81587ef..caeb381f 100644
--- a/src/SMAPI.Web/appsettings.json
+++ b/src/SMAPI.Web/appsettings.json
@@ -16,17 +16,17 @@
},
"Site": {
- "BetaEnabled": null,
- "BetaBlurb": null
+ "BetaEnabled": false,
+ "BetaBlurb": null,
+ "SupporterList": null
},
"ApiClients": {
"UserAgent": "SMAPI/{0} (+https://smapi.io)",
- "AmazonAccessKey": null,
- "AmazonSecretKey": null,
- "AmazonRegion": "us-east-1",
- "AmazonLogBucket": "smapi-log-parser",
+ "AzureBlobConnectionString": null,
+ "AzureBlobTempContainer": "smapi-web-temp",
+ "AzureBlobTempExpiryDays": 30,
"ChucklefishBaseUrl": "https://community.playstarbound.com",
"ChucklefishModPageUrlFormat": "resources/{0}",
@@ -46,16 +46,12 @@
"NexusModUrlFormat": "mods/{0}",
"NexusModScrapeUrlFormat": "mods/{0}?tab=files",
- "PastebinBaseUrl": "https://pastebin.com/",
- "PastebinUserKey": null,
- "PastebinDevKey": null
+ "PastebinBaseUrl": "https://pastebin.com/"
},
"MongoDB": {
- "Host": null,
- "Username": null,
- "Password": null,
- "Database": null
+ "ConnectionString": null,
+ "Database": "smapi"
},
"ModCompatibilityList": {
diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
index cd117694..18195098 100644
--- a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
+++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css
@@ -41,6 +41,12 @@
background: #FCC;
}
+.save-metadata {
+ margin-top: 1em;
+ font-size: 0.8em;
+ opacity: 0.3;
+}
+
/*********
** Validation results
*********/
diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
index 76b5f6d4..72b8192b 100644
--- a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
+++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js
@@ -70,10 +70,10 @@ smapi.LineNumberRange = function (maxLines) {
/**
* UI logic for the JSON validator page.
- * @param {any} sectionUrl The base JSON validator page URL.
- * @param {any} pasteID The Pastebin paste ID for the content being viewed, if any.
+ * @param {string} urlFormat The URL format for a file, with $schemaName and $id placeholders.
+ * @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.
*/
@@ -138,7 +138,7 @@ smapi.jsonValidator = function (sectionUrl, pasteID) {
// change format
$("#output #format").on("change", function() {
var schemaName = $(this).val();
- location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString();
+ location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", fileId);
});
// upload form
diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
index 61a633cb..7e00c28e 100644
--- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
+++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json
@@ -11,9 +11,9 @@
"title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string",
- "const": "1.9",
+ "const": "1.11.0",
"@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": {
@@ -51,8 +51,7 @@
"if": {
"properties": {
"AllowBlank": { "const": false }
- },
- "required": [ "AllowBlank" ]
+ }
},
"then": {
"required": [ "Default" ]
@@ -194,6 +193,8 @@
}
},
"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",
"items": {
"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": {
"title": "When",
"description": "Only apply the patch if the given conditions match.",
@@ -267,6 +276,9 @@
},
"allOf": [
{
+ "required": [ "Action" ]
+ },
+ {
"if": {
"properties": {
"Action": { "const": "Load" }
@@ -300,7 +312,7 @@
},
"then": {
"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": {
"properties": {
"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": {
"description": "The part of the source map to copy. Defaults to the whole source map."
@@ -323,9 +335,8 @@
}
},
"propertyNames": {
- "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ]
- },
- "required": [ "FromFile", "ToArea" ]
+ "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties" ]
+ }
}
}
],
@@ -355,26 +366,26 @@
"properties": {
"X": {
"title": "X-Coordinate",
- "description": "Location in pixels of the top-left of the rectangle",
- "type": "integer",
+ "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", "string" ],
"minimum:": 0
},
"Y": {
"title": "Y-Coordinate",
- "description": "Location in pixels of the top-left of the rectangle",
- "type": "integer",
+ "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", "string" ],
"minimum:": 0
},
"Width": {
"title": "Width",
- "description": "The width of the rectangle",
- "type": "integer",
+ "description": "The width of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
+ "type": [ "integer", "string" ],
"minimum:": 0
},
"Height": {
"title": "Height",
- "description": "The height of the rectangle",
- "type": "integer",
+ "description": "The height of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
+ "type": [ "integer", "string" ],
"minimum:": 0
}
},
diff --git a/src/SMAPI/Constants.cs b/src/SMAPI/Constants.cs
index 7fdfb8d0..97204d86 100644
--- a/src/SMAPI/Constants.cs
+++ b/src/SMAPI/Constants.cs
@@ -20,7 +20,7 @@ namespace StardewModdingAPI
** Public
****/
/// <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>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0");
diff --git a/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs b/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs
new file mode 100644
index 00000000..4b4c4210
--- /dev/null
+++ b/src/SMAPI/Events/ChestInventoryChangedEventArgs.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/SMAPI/Events/IWorldEvents.cs b/src/SMAPI/Events/IWorldEvents.cs
index 0ceffcc1..9569a57b 100644
--- a/src/SMAPI/Events/IWorldEvents.cs
+++ b/src/SMAPI/Events/IWorldEvents.cs
@@ -23,6 +23,9 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after objects are added or removed in a location.</summary>
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>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
}
diff --git a/src/SMAPI/Events/InventoryChangedEventArgs.cs b/src/SMAPI/Events/InventoryChangedEventArgs.cs
index 874c2e48..40cd4128 100644
--- a/src/SMAPI/Events/InventoryChangedEventArgs.cs
+++ b/src/SMAPI/Events/InventoryChangedEventArgs.cs
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
-using System.Linq;
using StardewValley;
namespace StardewModdingAPI.Events
@@ -14,13 +13,13 @@ namespace StardewModdingAPI.Events
/// <summary>The player whose inventory changed.</summary>
public Farmer Player { get; }
- /// <summary>The added items.</summary>
+ /// <summary>The added item stacks.</summary>
public IEnumerable<Item> Added { get; }
- /// <summary>The removed items.</summary>
+ /// <summary>The removed item stacks.</summary>
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; }
/// <summary>Whether the affected player is the local one.</summary>
@@ -32,28 +31,15 @@ namespace StardewModdingAPI.Events
*********/
/// <summary>Construct an instance.</summary>
/// <param name="player">The player whose inventory changed.</param>
- /// <param name="changedItems">The inventory changes.</param>
- internal InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems)
+ /// <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 InventoryChangedEventArgs(Farmer player, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
{
this.Player = player;
- this.Added = changedItems
- .Where(n => n.ChangeType == ChangeType.Added)
- .Select(p => p.Item)
- .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();
+ this.Added = added;
+ this.Removed = removed;
+ this.QuantityChanged = quantityChanged;
}
}
}
diff --git a/src/SMAPI/Events/ItemStackChange.cs b/src/SMAPI/Events/ItemStackChange.cs
deleted file mode 100644
index f9ae6df6..00000000
--- a/src/SMAPI/Events/ItemStackChange.cs
+++ /dev/null
@@ -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; }
- }
-} \ No newline at end of file
diff --git a/src/SMAPI/Framework/Content/AssetDataForImage.cs b/src/SMAPI/Framework/Content/AssetDataForImage.cs
index 4ae2ad68..aa615a0b 100644
--- a/src/SMAPI/Framework/Content/AssetDataForImage.cs
+++ b/src/SMAPI/Framework/Content/AssetDataForImage.cs
@@ -42,8 +42,8 @@ namespace StardewModdingAPI.Framework.Content
Texture2D target = this.Data;
// get areas
- sourceArea = 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));
+ sourceArea ??= new Rectangle(0, 0, source.Width, source.Height);
+ targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
// validate
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)
diff --git a/src/SMAPI/Framework/Content/AssetInterceptorChange.cs b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
new file mode 100644
index 00000000..037d9f89
--- /dev/null
+++ b/src/SMAPI/Framework/Content/AssetInterceptorChange.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/Content/ContentCache.cs b/src/SMAPI/Framework/Content/ContentCache.cs
index 4178b663..f33ff84d 100644
--- a/src/SMAPI/Framework/Content/ContentCache.cs
+++ b/src/SMAPI/Framework/Content/ContentCache.cs
@@ -119,13 +119,12 @@ namespace StardewModdingAPI.Framework.Content
/// <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>
/// <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>();
foreach (string key in this.Cache.Keys.ToArray())
{
- Type type = this.Cache[key].GetType();
- if (predicate(key, type))
+ if (predicate(key, this.Cache[key]))
{
this.Remove(key, dispose);
removed.Add(key);
diff --git a/src/SMAPI/Framework/ContentCoordinator.cs b/src/SMAPI/Framework/ContentCoordinator.cs
index 08ebe6a5..82d3805b 100644
--- a/src/SMAPI/Framework/ContentCoordinator.cs
+++ b/src/SMAPI/Framework/ContentCoordinator.cs
@@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
-using System.Reflection;
using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
@@ -188,59 +188,6 @@ namespace StardewModdingAPI.Framework
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>
/// <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>
@@ -261,24 +208,28 @@ namespace StardewModdingAPI.Framework
/// <returns>Returns the invalidated asset names.</returns>
public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
- // invalidate cache
- IDictionary<string, Type> removedAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
+ // invalidate cache & track removed assets
+ IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
foreach (IContentManager contentManager in this.ContentManagers)
{
- foreach (Tuple<string, Type> asset in contentManager.InvalidateCache(predicate, dispose))
- removedAssetNames[asset.Item1] = asset.Item2;
+ foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
+ {
+ if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
+ removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
+ assets.Add(entry.Value);
+ }
}
// reload core game assets
- int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager
-
- // report result
- if (removedAssetNames.Any())
- this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace);
+ if (removedAssets.Any())
+ {
+ IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager
+ 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);
+ }
else
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
- return removedAssetNames.Keys;
+ return removedAssets.Keys;
}
/// <summary>Dispose held resources.</summary>
@@ -308,33 +259,5 @@ namespace StardewModdingAPI.Framework
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.");
- }
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
index 5283340e..36f2f650 100644
--- a/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/BaseContentManager.cs
@@ -41,6 +41,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>A list of disposable assets.</summary>
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
@@ -84,6 +88,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get asset data
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>
@@ -184,25 +189,25 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary>
/// <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>
- /// <returns>Returns the invalidated asset names and types.</returns>
- public IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
+ /// <returns>Returns the invalidated asset names and instances.</returns>
+ public IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{
- Dictionary<string, Type> removeAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase);
- this.Cache.Remove((key, type) =>
+ IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
+ this.Cache.Remove((key, asset) =>
{
this.ParseCacheKey(key, out string assetName, out _);
- if (removeAssetNames.ContainsKey(assetName))
+ if (removeAssets.ContainsKey(assetName))
return true;
- if (predicate(assetName, type))
+ if (predicate(assetName, asset.GetType()))
{
- removeAssetNames[assetName] = type;
+ removeAssets[assetName] = asset;
return true;
}
return false;
- });
+ }, dispose);
- return removeAssetNames.Select(p => Tuple.Create(p.Key, p.Value));
+ return removeAssets;
}
/// <summary>Dispose held resources.</summary>
@@ -258,20 +263,27 @@ namespace StardewModdingAPI.Framework.ContentManagers
: 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>
/// <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="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
if (value is Texture2D texture)
texture.Name = assetName;
// cache asset
- assetName = this.AssertAndNormalizeAssetName(assetName);
- this.Cache[assetName] = value;
+ if (useCache)
+ {
+ assetName = this.AssertAndNormalizeAssetName(assetName);
+ 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>
diff --git a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
index 0b563555..8930267d 100644
--- a/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/GameContentManager.cs
@@ -83,8 +83,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
{
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
- if (useCache)
- this.Inject(assetName, managedAsset, language);
+ this.TrackAsset(assetName, managedAsset, language, useCache);
return managedAsset;
}
@@ -111,7 +110,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
}
// update cache & return data
- this.Inject(assetName, data, language);
+ this.TrackAsset(assetName, data, language, useCache);
return data;
}
@@ -131,7 +130,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
removeAssetNames.Contains(key)
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
)
- .Select(p => p.Item1)
+ .Select(p => p.Key)
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
.ToArray();
if (invalidated.Any())
@@ -169,18 +168,19 @@ namespace StardewModdingAPI.Framework.ContentManagers
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>
/// <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="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
{
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
{
- this.Inject(newAssetName, value, newLanguage);
+ this.TrackAsset(newAssetName, value, newLanguage, useCache);
return;
}
}
@@ -192,24 +192,27 @@ namespace StardewModdingAPI.Framework.ContentManagers
// only caches by the most specific key).
// 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`.
- string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
- base.Inject(assetName, value, language);
- if (this.Cache.ContainsKey(keyWithLocale))
- base.Inject(keyWithLocale, value, language);
-
- // track whether the injected asset is translatable for is-loaded lookups
- if (this.Cache.ContainsKey(keyWithLocale))
- {
- this.IsLocalizableLookup[assetName] = true;
- this.IsLocalizableLookup[keyWithLocale] = true;
- }
- else if (this.Cache.ContainsKey(assetName))
+ if (useCache)
{
- this.IsLocalizableLookup[assetName] = false;
- this.IsLocalizableLookup[keyWithLocale] = false;
+ string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
+ base.TrackAsset(assetName, value, language, useCache: true);
+ if (this.Cache.ContainsKey(keyWithLocale))
+ base.TrackAsset(keyWithLocale, value, language, useCache: true);
+
+ // track whether the injected asset is translatable for is-loaded lookups
+ if (this.Cache.ContainsKey(keyWithLocale))
+ {
+ this.IsLocalizableLookup[assetName] = true;
+ this.IsLocalizableLookup[keyWithLocale] = true;
+ }
+ else if (this.Cache.ContainsKey(assetName))
+ {
+ this.IsLocalizableLookup[assetName] = false;
+ this.IsLocalizableLookup[keyWithLocale] = false;
+ }
+ else
+ this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
}
- else
- 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>
diff --git a/src/SMAPI/Framework/ContentManagers/IContentManager.cs b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
index 12c01352..8da9a777 100644
--- a/src/SMAPI/Framework/ContentManagers/IContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/IContentManager.cs
@@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary>
/// <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>
- /// <returns>Returns the invalidated asset names and types.</returns>
- IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
+ /// <returns>Returns the invalidated asset names and instances.</returns>
+ IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
}
}
diff --git a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
index 90b86179..fdf76b24 100644
--- a/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
+++ b/src/SMAPI/Framework/ContentManagers/ModContentManager.cs
@@ -105,6 +105,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get local asset
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
+ T asset;
try
{
// get file
@@ -118,22 +119,22 @@ namespace StardewModdingAPI.Framework.ContentManagers
// XNB file
case ".xnb":
{
- T data = this.RawLoad<T>(assetName, useCache: false);
- if (data is Map map)
+ asset = this.RawLoad<T>(assetName, useCache: false);
+ if (asset is Map map)
{
this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
}
- return data;
}
+ break;
// unpacked data
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
- return data;
}
+ break;
// unpacked image
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)}'.");
// fetch & cache
- using (FileStream stream = File.OpenRead(file.FullName))
- {
- Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
- texture = this.PremultiplyTransparency(texture);
- return (T)(object)texture;
- }
+ using FileStream stream = File.OpenRead(file.FullName);
+
+ Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
+ texture = this.PremultiplyTransparency(texture);
+ asset = (T)(object)texture;
}
+ break;
// unpacked map
case ".tbin":
@@ -163,8 +164,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
Map map = formatManager.LoadMap(file.FullName);
this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
- return (T)(object)map;
+ asset = (T)(object)map;
}
+ break;
default:
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 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>
diff --git a/src/SMAPI/Framework/Events/EventManager.cs b/src/SMAPI/Framework/Events/EventManager.cs
index 18b00f69..892cbc7b 100644
--- a/src/SMAPI/Framework/Events/EventManager.cs
+++ b/src/SMAPI/Framework/Events/EventManager.cs
@@ -148,6 +148,9 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Raised after objects are added or removed in a location.</summary>
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>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
@@ -221,6 +224,7 @@ namespace StardewModdingAPI.Framework.Events
this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
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.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));
diff --git a/src/SMAPI/Framework/Events/ModWorldEvents.cs b/src/SMAPI/Framework/Events/ModWorldEvents.cs
index b85002a3..2ae69669 100644
--- a/src/SMAPI/Framework/Events/ModWorldEvents.cs
+++ b/src/SMAPI/Framework/Events/ModWorldEvents.cs
@@ -51,6 +51,13 @@ namespace StardewModdingAPI.Framework.Events
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>
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
{
diff --git a/src/SMAPI/Framework/IModMetadata.cs b/src/SMAPI/Framework/IModMetadata.cs
index 6ee7df69..37927482 100644
--- a/src/SMAPI/Framework/IModMetadata.cs
+++ b/src/SMAPI/Framework/IModMetadata.cs
@@ -105,6 +105,10 @@ namespace StardewModdingAPI.Framework
/// <param name="validOnly">Only return valid update keys.</param>
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>
bool HasValidUpdateKeys();
diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs
index d69e5604..84cea36c 100644
--- a/src/SMAPI/Framework/Input/SInputState.cs
+++ b/src/SMAPI/Framework/Input/SInputState.cs
@@ -129,6 +129,9 @@ namespace StardewModdingAPI.Framework.Input
[Obsolete("This method should only be called by the game itself.")]
public override GamePadState GetGamePadState()
{
+ if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff)
+ return base.GetGamePadState();
+
return this.ShouldSuppressNow()
? this.SuppressedController
: this.RealController;
diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
index cc08c42b..3d43c539 100644
--- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs
+++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs
@@ -1,6 +1,8 @@
using System;
+using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
+using StardewModdingAPI.Enums;
using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities;
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>
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.");
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.)");
- return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value)
- ? this.JsonHelper.Deserialize<TModel>(value)
- : null;
+
+ string internalKey = this.GetSaveFileKey(key);
+ 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>
/// <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="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>
- 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.");
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.)");
string internalKey = this.GetSaveFileKey(key);
- if (data != null)
- Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None);
- else
- Game1.CustomData.Remove(internalKey);
+ string data = model != null
+ ? this.JsonHelper.Serialize(model, Formatting.None)
+ : null;
+
+ foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
+ {
+ if (data != null)
+ dataField[internalKey] = data;
+ else
+ dataField.Remove(internalKey);
+ }
}
/****
@@ -146,6 +160,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
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>
/// <param name="key">The unique key identifying the data.</param>
private string GetGlobalDataPath(string key)
diff --git a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
index 7670eb3a..b5533335 100644
--- a/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
+++ b/src/SMAPI/Framework/ModLoading/AssemblyLoader.cs
@@ -356,6 +356,11 @@ namespace StardewModdingAPI.Framework.ModLoading
mod.SetWarning(ModWarning.UsesDynamic);
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:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}.");
mod.SetWarning(ModWarning.AccessesFilesystem);
diff --git a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
index d93b603d..a948213b 100644
--- a/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
+++ b/src/SMAPI/Framework/ModLoading/InstructionHandleResult.cs
@@ -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>
DetectedUnvalidatedUpdateTick,
+ /// <summary>The instruction accesses the SMAPI console directly.</summary>
+ DetectedConsoleAccess,
+
/// <summary>The instruction accesses the filesystem directly.</summary>
DetectedFilesystemAccess,
diff --git a/src/SMAPI/Framework/ModLoading/ModMetadata.cs b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
index 7f788d17..0e90362e 100644
--- a/src/SMAPI/Framework/ModLoading/ModMetadata.cs
+++ b/src/SMAPI/Framework/ModLoading/ModMetadata.cs
@@ -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>
public bool HasValidUpdateKeys()
{
diff --git a/src/SMAPI/Framework/Models/ModFolderExport.cs b/src/SMAPI/Framework/Models/ModFolderExport.cs
deleted file mode 100644
index 3b8d451a..00000000
--- a/src/SMAPI/Framework/Models/ModFolderExport.cs
+++ /dev/null
@@ -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; }
- }
-}
diff --git a/src/SMAPI/Framework/Models/SConfig.cs b/src/SMAPI/Framework/Models/SConfig.cs
index 53939f8c..b1612aa4 100644
--- a/src/SMAPI/Framework/Models/SConfig.cs
+++ b/src/SMAPI/Framework/Models/SConfig.cs
@@ -25,8 +25,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(VerboseLogging)] = false,
- [nameof(LogNetworkTraffic)] = false,
- [nameof(DumpMetadata)] = false
+ [nameof(LogNetworkTraffic)] = false
};
/// <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>
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>
public ColorSchemeConfig ConsoleColors { get; set; }
diff --git a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
index 039f27c3..82737a7f 100644
--- a/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
+++ b/src/SMAPI/Framework/Reflection/ReflectedMethod.cs
@@ -65,6 +65,10 @@ namespace StardewModdingAPI.Framework.Reflection
{
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)
{
throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex);
diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs
index f1873391..dfd77e16 100644
--- a/src/SMAPI/Framework/SCore.cs
+++ b/src/SMAPI/Framework/SCore.cs
@@ -97,16 +97,25 @@ namespace StardewModdingAPI.Framework
};
/// <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(
- new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ // Steam not loaded
+ new ReplaceLogPattern(
+ search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
+ replacement:
#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
- "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
- 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();
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
this.CheckForUpdatesAsync(mods);
}
@@ -774,7 +769,7 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log(
$" {metadata.DisplayName} {manifest.Version}"
+ (!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}" : ""),
LogLevel.Info
);
@@ -842,34 +837,11 @@ namespace StardewModdingAPI.Framework
{
if (metadata.Mod.Helper.Content is ContentHelper helper)
{
- helper.ObservableAssetEditors.CollectionChanged += (sender, e) =>
- {
- 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());
- }
- };
+ 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);
}
}
- // 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
this.ModRegistry.AreAllModsInitialized = true;
}
@@ -1060,26 +1032,48 @@ namespace StardewModdingAPI.Framework
// log skipped mods
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(" " + "".PadRight(50, '-'), LogLevel.Error);
this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
this.Monitor.Newline();
- HashSet<string> logged = new HashSet<string>();
- foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ if (skippedDependencies.Any())
{
- IModMetadata mod = pair.Key;
- string errorReason = pair.Value.Item1;
- string errorDetails = pair.Value.Item2;
- 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 skippedDependencies.OrderBy(p => p.Key.DisplayName))
+ LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
+ this.Monitor.Newline();
}
+
+ foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
+ LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
this.Monitor.Newline();
}
@@ -1116,6 +1110,10 @@ namespace StardewModdingAPI.Framework
);
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",
"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.)"
@@ -1317,11 +1315,12 @@ namespace StardewModdingAPI.Framework
return;
// 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);
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;
+ }
+ }
}
}
diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs
index 47261862..d6c3b836 100644
--- a/src/SMAPI/Framework/SGame.cs
+++ b/src/SMAPI/Framework/SGame.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -12,10 +13,12 @@ using Microsoft.Xna.Framework.Graphics;
using Netcode;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Reflection;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Toolkit.Serialization;
@@ -99,7 +102,7 @@ namespace StardewModdingAPI.Framework
private WatcherCore Watchers;
/// <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>
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>
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
@@ -249,6 +255,24 @@ namespace StardewModdingAPI.Framework
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>
/// <param name="serviceProvider">The service provider to use to locate services.</param>
/// <param name="rootDirectory">The root directory to search for content.</param>
@@ -405,6 +429,38 @@ namespace StardewModdingAPI.Framework
}
/*********
+ ** 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
*********/
while (this.CommandQueue.TryDequeue(out string rawInput))
@@ -654,6 +710,16 @@ namespace StardewModdingAPI.Framework
if (locState.Objects.IsChanged)
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
if (locState.TerrainFeatures.IsChanged)
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
@@ -692,12 +758,13 @@ namespace StardewModdingAPI.Framework
}
// raise player inventory changed
- ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray();
- if (changedItems.Any())
+ if (playerState.Inventory.IsChanged)
{
+ var inventory = playerState.Inventory;
+
if (this.Monitor.IsVerbose)
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));
}
}
}
diff --git a/src/SMAPI/Framework/SnapshotItemListDiff.cs b/src/SMAPI/Framework/SnapshotItemListDiff.cs
new file mode 100644
index 00000000..e8ab1b1e
--- /dev/null
+++ b/src/SMAPI/Framework/SnapshotItemListDiff.cs
@@ -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;
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/ChestTracker.cs b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
new file mode 100644
index 00000000..65f58ee7
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/ChestTracker.cs
@@ -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();
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs
new file mode 100644
index 00000000..0b4d3030
--- /dev/null
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/NetListWatcher.cs
@@ -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);
+ }
+ }
+}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
index 883b1023..c29d2783 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/ObservableCollectionWatcher.cs
@@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>The pairs removed since the last reset.</summary>
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
@@ -78,10 +81,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <param name="e">The event arguments.</param>
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{
- if (e.NewItems != null)
- this.AddedImpl.AddRange(e.NewItems.Cast<TValue>());
- if (e.OldItems != null)
- this.RemovedImpl.AddRange(e.OldItems.Cast<TValue>());
+ if (e.Action == NotifyCollectionChangedAction.Reset)
+ {
+ this.RemovedImpl.AddRange(this.PreviousValues);
+ 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);
+ }
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
index 314ff7f5..bde43486 100644
--- a/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
+++ b/src/SMAPI/Framework/StateTracking/FieldWatchers/WatcherFactory.cs
@@ -82,6 +82,14 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
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>
/// <typeparam name="TKey">The dictionary key type.</typeparam>
/// <typeparam name="TValue">The dictionary value type.</typeparam>
diff --git a/src/SMAPI/Framework/StateTracking/LocationTracker.cs b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
index 1f479e12..519fe8f4 100644
--- a/src/SMAPI/Framework/StateTracking/LocationTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/LocationTracker.cs
@@ -5,8 +5,9 @@ using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley;
using StardewValley.Buildings;
using StardewValley.Locations;
+using StardewValley.Objects;
using StardewValley.TerrainFeatures;
-using Object = StardewValley.Object;
+using SObject = StardewValley.Object;
namespace StardewModdingAPI.Framework.StateTracking
{
@@ -42,11 +43,14 @@ namespace StardewModdingAPI.Framework.StateTracking
public ICollectionWatcher<NPC> NpcsWatcher { get; }
/// <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>
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
@@ -74,13 +78,8 @@ namespace StardewModdingAPI.Framework.StateTracking
this.ObjectsWatcher,
this.TerrainFeaturesWatcher
});
- }
- /// <summary>Stop watching the player fields and release all references.</summary>
- public void Dispose()
- {
- foreach (IWatcher watcher in this.Watchers)
- watcher.Dispose();
+ this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);
}
/// <summary>Update the current value if needed.</summary>
@@ -88,6 +87,11 @@ namespace StardewModdingAPI.Framework.StateTracking
{
foreach (IWatcher watcher in this.Watchers)
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>
@@ -95,6 +99,46 @@ namespace StardewModdingAPI.Framework.StateTracking
{
foreach (IWatcher watcher in this.Watchers)
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));
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
index 6302a889..cf49a7c1 100644
--- a/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
+++ b/src/SMAPI/Framework/StateTracking/PlayerTracker.cs
@@ -2,10 +2,9 @@ using System;
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Enums;
-using StardewModdingAPI.Events;
+using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley;
-using ChangeType = StardewModdingAPI.Events.ChangeType;
namespace StardewModdingAPI.Framework.StateTracking
{
@@ -99,25 +98,32 @@ namespace StardewModdingAPI.Framework.StateTracking
return this.Player.currentLocation ?? this.LastValidLocation;
}
- /// <summary>Get the player inventory changes between two states.</summary>
- public IEnumerable<ItemStackChange> GetInventoryChanges()
+ /// <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)
{
- IDictionary<Item, int> previous = this.PreviousInventory;
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))
- yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added };
- else if (!current.TryGetValue(item, out int newStack))
- yield return new ItemStackChange { Item = item, StackChange = -item.Stack, ChangeType = ChangeType.Removed };
- else if (prevStack != newStack)
- yield return new ItemStackChange { Item = item, StackChange = newStack - prevStack, ChangeType = ChangeType.StackChange };
+ if (!this.PreviousInventory.ContainsKey(item))
+ added.Add(item);
+ else if (!current.ContainsKey(item))
+ removed.Add(item);
}
+
+ return SnapshotItemListDiff.TryGetChanges(added: added, removed: removed, stackSizes: this.PreviousInventory, out changes);
}
- /// <summary>Stop watching the player fields and release all references.</summary>
+ /// <summary>Release watchers and resources.</summary>
public void Dispose()
{
+ this.PreviousInventory.Clear();
+ this.CurrentInventory?.Clear();
+
foreach (IWatcher watcher in this.Watchers)
watcher.Dispose();
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
index d3029540..6ae52fd0 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/LocationSnapshot.cs
@@ -2,6 +2,7 @@ using System.Collections.Generic;
using Microsoft.Xna.Framework;
using StardewValley;
using StardewValley.Buildings;
+using StardewValley.Objects;
using StardewValley.TerrainFeatures;
namespace StardewModdingAPI.Framework.StateTracking.Snapshots
@@ -33,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <summary>Tracks added or removed terrain features.</summary>
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
@@ -48,12 +52,21 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <param name="watcher">The watcher to snapshot.</param>
public void Update(LocationTracker watcher)
{
+ // main lists
this.Buildings.Update(watcher.BuildingsWatcher);
this.Debris.Update(watcher.DebrisWatcher);
this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher);
this.Npcs.Update(watcher.NpcsWatcher);
this.Objects.Update(watcher.ObjectsWatcher);
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;
+ }
}
}
}
diff --git a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
index 7bcd9f82..f0fb9485 100644
--- a/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
+++ b/src/SMAPI/Framework/StateTracking/Snapshots/PlayerSnapshot.cs
@@ -11,6 +11,13 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
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
*********/
/// <summary>The player being tracked.</summary>
@@ -27,7 +34,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
.ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
/// <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);
foreach (var pair in this.Skills)
pair.Value.Update(watcher.SkillWatchers[pair.Key]);
- this.InventoryChanges = watcher.GetInventoryChanges().ToArray();
+
+ this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges)
+ ? itemChanges
+ : this.EmptyItemListDiff;
+
}
}
}
diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs
index 1c0a04f0..b86a6790 100644
--- a/src/SMAPI/Metadata/CoreAssetPropagator.cs
+++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Xna.Framework.Graphics;
+using Netcode;
using StardewModdingAPI.Framework.Reflection;
using StardewValley;
using StardewValley.BellsAndWhistles;
@@ -11,6 +12,7 @@ using StardewValley.Characters;
using StardewValley.GameData.Movies;
using StardewValley.Locations;
using StardewValley.Menus;
+using StardewValley.Network;
using StardewValley.Objects;
using StardewValley.Projectiles;
using StardewValley.TerrainFeatures;
@@ -65,8 +67,8 @@ namespace StardewModdingAPI.Metadata
/// <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="assets">The asset keys and types to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- public int Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
+ /// <returns>Returns a lookup of asset names to whether they've been propagated.</returns>
+ public IDictionary<string, bool> Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
{
// group into optimized lists
var buckets = assets.GroupBy(p =>
@@ -81,25 +83,26 @@ namespace StardewModdingAPI.Metadata
});
// reload assets
- int reloaded = 0;
+ IDictionary<string, bool> propagated = assets.ToDictionary(p => p.Key, p => false, StringComparer.InvariantCultureIgnoreCase);
foreach (var bucket in buckets)
{
switch (bucket.Key)
{
case AssetBucket.Sprite:
- reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key));
+ this.ReloadNpcSprites(content, bucket.Select(p => p.Key), propagated);
break;
case AssetBucket.Portrait:
- reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key));
+ this.ReloadNpcPortraits(content, bucket.Select(p => p.Key), propagated);
break;
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;
}
}
- return reloaded;
+ return propagated;
}
@@ -193,7 +196,7 @@ namespace StardewModdingAPI.Metadata
return true;
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)
return false;
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
@@ -226,6 +229,31 @@ namespace StardewModdingAPI.Metadata
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
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
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
return true;
@@ -474,10 +502,18 @@ namespace StardewModdingAPI.Metadata
/****
** Content\TerrainFeatures
****/
- case "terrainfeatures\\flooring": // Flooring
+ case "terrainfeatures\\flooring": // from Flooring
Flooring.floorsTexture = content.Load<Texture2D>(key);
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
HoeDirt.lightTexture = content.Load<Texture2D>(key);
return true;
@@ -607,7 +643,7 @@ namespace StardewModdingAPI.Metadata
{
// get buildings
string type = Path.GetFileName(key);
- Building[] buildings = Game1.locations
+ Building[] buildings = this.GetLocations(buildingInteriors: false)
.OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings)
.Where(p => p.buildingType.Value == type)
@@ -694,6 +730,35 @@ namespace StardewModdingAPI.Metadata
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>
/// <param name="content">The content manager through which to reload the asset.</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>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset keys to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys)
+ /// <param name="propagated">The asset keys which have been propagated.</param>
+ private void ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{
// get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
- NPC[] characters = this.GetCharacters()
- .Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)))
+ var characters =
+ (
+ 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();
if (!characters.Any())
- return 0;
+ return;
// update sprite
- int reloaded = 0;
- foreach (NPC npc in characters)
+ foreach (var target in characters)
{
- this.SetSpriteTexture(npc.Sprite, content.Load<Texture2D>(npc.Sprite.textureName.Value));
- reloaded++;
+ this.SetSpriteTexture(target.Npc.Sprite, content.Load<Texture2D>(target.Key));
+ propagated[target.Key] = true;
}
-
- return reloaded;
}
/// <summary>Reload the portraits for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset key to reload.</param>
- /// <returns>Returns the number of reloaded assets.</returns>
- private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys)
+ /// <param name="propagated">The asset keys which have been propagated.</param>
+ private void ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{
// get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
- var villagers = this
- .GetCharacters()
- .Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)))
+ var characters =
+ (
+ 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();
- if (!villagers.Any())
- return 0;
+ if (!characters.Any())
+ return;
// update portrait
- int reloaded = 0;
- foreach (NPC npc in villagers)
+ foreach (var target in characters)
{
- npc.Portrait = content.Load<Texture2D>(npc.Portrait.Name);
- reloaded++;
+ target.Npc.Portrait = content.Load<Texture2D>(target.Key);
+ propagated[target.Key] = true;
}
- return reloaded;
}
/// <summary>Reload tree textures.</summary>
@@ -771,7 +842,7 @@ namespace StardewModdingAPI.Metadata
/// <returns>Returns whether any textures were reloaded.</returns>
private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
{
- Tree[] trees = Game1.locations
+ Tree[] trees = this.GetLocations()
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
.Where(tree => tree.treeType.Value == type)
.ToArray();
@@ -876,7 +947,8 @@ namespace StardewModdingAPI.Metadata
}
/// <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
IEnumerable<GameLocation> rootLocations = Game1.locations;
@@ -888,7 +960,7 @@ namespace StardewModdingAPI.Metadata
{
yield return location;
- if (location is BuildableGameLocation buildableLocation)
+ if (buildingInteriors && location is BuildableGameLocation buildableLocation)
{
foreach (Building building in buildableLocation.buildings)
{
diff --git a/src/SMAPI/Metadata/InstructionMetadata.cs b/src/SMAPI/Metadata/InstructionMetadata.cs
index 95482708..eee5c235 100644
--- a/src/SMAPI/Metadata/InstructionMetadata.cs
+++ b/src/SMAPI/Metadata/InstructionMetadata.cs
@@ -60,6 +60,7 @@ namespace StardewModdingAPI.Metadata
if (paranoidMode)
{
// 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.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess);
yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess);
diff --git a/src/SMAPI/SMAPI.config.json b/src/SMAPI/SMAPI.config.json
index a7381b91..824bb783 100644
--- a/src/SMAPI/SMAPI.config.json
+++ b/src/SMAPI/SMAPI.config.json
@@ -60,12 +60,6 @@ The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to
"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 possible values for 'UseScheme' are:
diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj
index 4952116f..936c420d 100644
--- a/src/SMAPI/SMAPI.csproj
+++ b/src/SMAPI/SMAPI.csproj
@@ -99,9 +99,30 @@
<Link>SMAPI.metadata.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</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">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</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">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
diff --git a/src/SMAPI/i18n/es.json b/src/SMAPI/i18n/es.json
new file mode 100644
index 00000000..f5a74dfe
--- /dev/null
+++ b/src/SMAPI/i18n/es.json
@@ -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)."
+}
diff --git a/src/SMAPI/i18n/ja.json b/src/SMAPI/i18n/ja.json
new file mode 100644
index 00000000..9bbc285e
--- /dev/null
+++ b/src/SMAPI/i18n/ja.json
@@ -0,0 +1,3 @@
+{
+ "warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました (詳細はSMAPIコンソールを参照)"
+}
diff --git a/src/SMAPI/i18n/pt.json b/src/SMAPI/i18n/pt.json
new file mode 100644
index 00000000..59273680
--- /dev/null
+++ b/src/SMAPI/i18n/pt.json
@@ -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)."
+}
diff --git a/src/SMAPI/i18n/zh.json b/src/SMAPI/i18n/zh.json
index bbd6a574..9c0e0c21 100644
--- a/src/SMAPI/i18n/zh.json
+++ b/src/SMAPI/i18n/zh.json
@@ -1,3 +1,3 @@
-{
- "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
-}
+{
+ "warn.invalid-content-removed": "非法内容已移除以防游戏闪退(查看SMAPI控制台获得更多信息)"
+}