Merge branch 'develop' into stable

This commit is contained in:
Jesse Plamondon-Willard 2020-01-05 20:18:16 -05:00
commit f976b5c0f0
No known key found for this signature in database
GPG Key ID: CF8B1456B3E29F49
85 changed files with 1849 additions and 1014 deletions

5
.gitignore vendored
View File

@ -18,6 +18,9 @@ _ReSharper*/
*.[Rr]e[Ss]harper *.[Rr]e[Ss]harper
*.DotSettings.user *.DotSettings.user
# Rider
.idea/
# NuGet packages # NuGet packages
*.nupkg *.nupkg
**/packages/* **/packages/*
@ -31,4 +34,4 @@ appsettings.Development.json
src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json src/SMAPI.Web.LegacyRedirects/aws-beanstalk-tools-defaults.json
# Azure generated files # Azure generated files
src/SMAPI.Web/Properties/PublishProfiles/smapi-web-release - Web Deploy.pubxml src/SMAPI.Web/Properties/PublishProfiles/*.pubxml

View File

@ -4,7 +4,7 @@
<!--set properties --> <!--set properties -->
<PropertyGroup> <PropertyGroup>
<Version>3.0.1</Version> <Version>3.1.0</Version>
<Product>SMAPI</Product> <Product>SMAPI</Product>
<AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths> <AssemblySearchPaths>$(AssemblySearchPaths);{GAC}</AssemblySearchPaths>

View File

@ -68,9 +68,9 @@ French | ❑ not translated
German | ✓ [fully translated](../src/SMAPI/i18n/de.json) German | ✓ [fully translated](../src/SMAPI/i18n/de.json)
Hungarian | ❑ not translated Hungarian | ❑ not translated
Italian | ❑ not translated Italian | ❑ not translated
Japanese | ❑ not translated Japanese | ✓ [fully translated](../src/SMAPI/i18n/ja.json)
Korean | ❑ not translated Korean | ❑ not translated
Portuguese | ❑ not translated Portuguese | ✓ [fully translated](../src/SMAPI/i18n/pt.json)
Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json) Russian | ✓ [fully translated](../src/SMAPI/i18n/ru.json)
Spanish | ❑ not translated Spanish | ✓ [fully translated](../src/SMAPI/i18n/es.json)
Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json) Turkish | ✓ [fully translated](../src/SMAPI/i18n/tr.json)

View File

@ -1,8 +1,52 @@
&larr; [README](README.md) &larr; [README](README.md)
# Release notes # Release notes
## 3.1
Released 05 January 2019 for Stardew Valley 1.4 or later.
* For players:
* Added separate group in 'skipped mods' list for broken dependencies, so it's easier to see what to fix first.
* Added friendly log message for save file-not-found errors.
* Updated for gamepad modes in Stardew Valley 1.4.1.
* Improved performance in some cases.
* Fixed compatibility with Linux Mint 18 (thanks to techge!), Arch Linux, and Linux systems with libhybris-utils installed.
* Fixed memory leak when repeatedly loading a save and returning to title.
* Fixed memory leak when mods reload assets.
* Fixes for Console Commands mod:
* added new clothing items;
* fixed spawning new flooring and rings (thanks to Mizzion!);
* fixed spawning custom rings added by mods;
* Fixed errors when some item data is invalid.
* Updated translations. Thanks to L30Bola (added Portuguese), PlussRolf (added Spanish), and shirutan (added Japanese)!
* For the web UI:
* Added option to edit & reupload in the JSON validator.
* File uploads are now stored in Azure storage instead of Pastebin, due to ongoing Pastebin perfomance issues.
* File uploads now expire after one month.
* Updated the JSON validator for Content Patcher 1.10 and 1.11.
* Fixed JSON validator no longer letting you change format when viewing a file.
* Fixed JSON validator for Content Patcher not requiring `Default` if `AllowBlank` was omitted.
* Fixed log parser not correctly handling content packs with no author (thanks to danvolchek!).
* Fixed main sidebar link pointing to wiki instead of home page.
* For modders:
* Added `World.ChestInventoryChanged` event (thanks to collaboration with wartech0!).
* Added asset propagation for...
* grass textures;
* winter flooring textures;
* `Data\Bundles` changes (for added bundles only);
* `Characters\Farmer\farmer_girl_base_bald`.
* Added paranoid-mode warning for direct `Console` access.
* Improved error messages for `TargetParameterCountException` when using the reflection API.
* `helper.Read/WriteSaveData` can now be used while a save is being loaded (e.g. within a `Specialized.LoadStageChanged` event).
* Removed `DumpMetadata` option. It was only for specific debugging cases, but players would sometimes enable it incorrectly and then report crashes.
* Fixed private textures loaded from content packs not having their `Name` field set.
* For SMAPI developers:
* You can now run local environments without configuring Amazon, Azure, MongoDB, and Pastebin accounts.
## 3.0.1 ## 3.0.1
Released 02 December 2019 for Stardew Valley 1.4.0.1. Released 02 December 2019 for Stardew Valley 1.4 or later.
* For players: * For players:
* Updated for Stardew Valley 1.4.0.1. * Updated for Stardew Valley 1.4.0.1.

View File

@ -40,7 +40,7 @@ property | description
`$(GamePath)` | The absolute path to the detected game folder. `$(GamePath)` | The absolute path to the detected game folder.
`$(GameExecutableName)` | The game's executable name for the current OS (`Stardew Valley` on Windows, or `StardewValley` on Linux/Mac). `$(GameExecutableName)` | The game's executable name for the current OS (`Stardew Valley` on Windows, or `StardewValley` on Linux/Mac).
If you get a build error saying it can't find your game, see [_set the game path_](#set-the-game-path). If you get a build error saying it can't find your game, see [_custom game path_](#custom-game-path).
### Add assembly references ### Add assembly references
The package adds assembly references to SMAPI, Stardew Valley, xTile, and MonoGame (Linux/Mac) or XNA The package adds assembly references to SMAPI, Stardew Valley, xTile, and MonoGame (Linux/Mac) or XNA
@ -228,7 +228,7 @@ or you have multiple installs, you can specify the path yourself. There's two wa
</Project> </Project>
``` ```
4. Replace `PATH_HERE` with your game path. 4. Replace `PATH_HERE` with your game's folder path.
* **Option 2: path in the project file.** * **Option 2: path in the project file.**
_You'll need to do this for each project that uses the package._ _You'll need to do this for each project that uses the package._

View File

@ -71,14 +71,14 @@ flag | purpose
### Compiling from source ### Compiling from source
Using an official SMAPI release is recommended for most users. Using an official SMAPI release is recommended for most users.
SMAPI uses some C# 7 code, so you'll need at least SMAPI often uses the latest C# syntax. You may need the latest version of
[Visual Studio 2017](https://www.visualstudio.com/vs/community/) on Windows, [Visual Studio](https://www.visualstudio.com/vs/community/) on Windows,
[MonoDevelop 7.0](https://www.monodevelop.com/) on Linux, [MonoDevelop](https://www.monodevelop.com/) on Linux,
[Visual Studio 2017 for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent [Visual Studio for Mac](https://www.visualstudio.com/vs/visual-studio-mac/), or an equivalent IDE
IDE to compile it. It uses build configuration derived from the to compile it. It uses build configuration derived from the
[crossplatform mod config](https://github.com/Pathoschild/Stardew.ModBuildConfig#readme) to detect [crossplatform mod config](https://smapi.io/package/readme) to detect your current OS automatically
your current OS automatically and load the correct references. Compile output will be placed in a and load the correct references. Compile output will be placed in a `bin` folder at the root of the
`bin` folder at the root of the git repository. git repository.
### Debugging a local build ### Debugging a local build
Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting Rebuilding the solution in debug mode will copy the SMAPI files into your game folder. Starting

View File

@ -10,17 +10,21 @@ and update check API.
* [Short URLs](#short-urls) * [Short URLs](#short-urls)
* [For SMAPI developers](#for-smapi-developers) * [For SMAPI developers](#for-smapi-developers)
* [Local development](#local-development) * [Local development](#local-development)
* [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk) * [Production environment](#production-environment)
## Log parser ## Log parser
The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are The log parser at https://smapi.io/log provides a web UI for uploading, parsing, and sharing SMAPI
persisted in a compressed form to Pastebin. The log parser lives at https://smapi.io/log. logs.
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
## JSON validator ## JSON validator
### Overview ### Overview
The JSON validator provides a web UI for uploading and sharing JSON files, and validating them as The JSON validator at https://smapi.io/json provides a web UI for uploading and sharing JSON files,
plain JSON or against a predefined format like `manifest.json` or Content Patcher's `content.json`. and validating them as plain JSON or against a predefined format like `manifest.json` or Content
The JSON validator lives at https://smapi.io/json. Patcher's `content.json`.
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
### Schema file format ### Schema file format
Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/) Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/)
@ -336,43 +340,44 @@ short url | → | target page
A local environment lets you run a complete copy of the web project (including cache database) on A local environment lets you run a complete copy of the web project (including cache database) on
your machine, with no external dependencies aside from the actual mod sites. your machine, with no external dependencies aside from the actual mod sites.
Initial setup: 1. Enter the Nexus credentials in `appsettings.Development.json` . You can leave the other
credentials empty to default to fetching data anonymously, and storing data in-memory and
1. [Install MongoDB](https://docs.mongodb.com/manual/administration/install-community/) and add its on disk.
`bin` folder to the system PATH.
2. Create a local folder for the MongoDB data (e.g. `C:\dev\smapi-cache`).
3. Enter your credentials in the `appsettings.Development.json` file. You can leave the MongoDB
credentials as-is to use the default local instance; see the next section for the other settings.
To launch the environment:
1. Launch MongoDB from a terminal (change the data path if applicable):
```sh
mongod --dbpath C:\dev\smapi-cache
```
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site. 2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
<small>(Local URLs will use HTTP instead of HTTPS.)</small>
### Production environment ### Production environment
A production environment includes the web servers and cache database hosted online for public A production environment includes the web servers and cache database hosted online for public
access. This section assumes you're creating a new production environment from scratch (not using access.
the official live environment).
This section assumes you're creating a new environment on Azure, but the app isn't tied to any
Azure services. If you want to host it on a different site, you'll need to adjust the instructions
accordingly.
Initial setup: Initial setup:
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)). 1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas))
2. Create an AWS Beanstalk .NET environment with these environment properties: for mod data.
2. Create an Azure Blob storage account for uploaded files.
3. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
4. Add these application settings in the new App Services environment:
property name | description property name | description
------------------------------- | ----------------- ------------------------------- | -----------------
`LogParser:PastebinDevKey` | The [Pastebin developer key](https://pastebin.com/api#1) used to authenticate with the Pastebin API. `ApiClients.AzureBlobConnectionString` | The connection string for the Azure Blob storage account created in step 2.
`LogParser:PastebinUserKey` | The [Pastebin user key](https://pastebin.com/api#8) used to authenticate with the Pastebin API. Can be left blank to post anonymously. `ApiClients.GitHubUsername`<br />`ApiClients.GitHubPassword` | The login credentials for the GitHub account with which to fetch release info. If these are omitted, GitHub will impose much stricter rate limits.
`ModUpdateCheck:GitHubPassword` | The password with which to authenticate to GitHub when fetching release info. `ApiClients:NexusApiKey` | The [Nexus API authentication key](https://github.com/Pathoschild/FluentNexus#init-a-client).
`ModUpdateCheck:GitHubUsername` | The username with which to authenticate to GitHub when fetching release info. `MongoDB:ConnectionString` | The connection string for the MongoDB instance.
`MongoDB:Host` | The hostname for the MongoDB instance. `MongoDB:Database` | The MongoDB database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
`MongoDB:Username` | The login username for the MongoDB instance.
`MongoDB:Password` | The login password for the MongoDB instance. Optional settings:
`MongoDB:Database` | The database name (e.g. `smapi` in production or `smapi-edge` in testing environments).
property name | description
------------------------------- | -----------------
`BackgroundServices:Enabled` | Set to `true` to enable background processes like fetching data from the wiki, or false to disable them.
`Site:BetaEnabled` | Set to `true` to show a separate download button if there's a beta version of SMAPI in its GitHub releases.
`Site:BetaBlurb` | If `Site:BetaEnabled` is true and there's a beta version of SMAPI in its GitHub releases, this is shown on the beta download button as explanatory subtext.
`Site:SupporterList` | A list of Patreon supports to credit on the download page.
To deploy updates: To deploy updates:
1. Deploy the web project using [AWS Toolkit for Visual Studio](https://aws.amazon.com/visualstudio/). 1. [Deploy the web project from Visual Studio](https://docs.microsoft.com/en-us/visualstudio/deployment/quickstart-deploy-to-azure).
2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.) 2. If the MongoDB schema changed, delete the MongoDB database. (It'll be recreated automatically.)

View File

@ -61,8 +61,8 @@ else
COMMAND="type" COMMAND="type"
fi fi
# select terminal (prefer $TERMINAL for overrides and testing, then xterm for best compatibility, then known supported terminals) # select terminal (prefer xterm for best compatibility, then known supported terminals)
for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty x-terminal-emulator; do for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty mate-terminal x-terminal-emulator; do
if $COMMAND "$terminal" 2>/dev/null; then if $COMMAND "$terminal" 2>/dev/null; then
# Find the true shell behind x-terminal-emulator # Find the true shell behind x-terminal-emulator
if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then if [ "$(basename "$(readlink -f $(which "$terminal"))")" != "x-terminal-emulator" ]; then
@ -108,7 +108,7 @@ else
alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*' alacritty -e sh -c 'TERM=xterm ./StardewModdingAPI.bin.x86 $*'
fi fi
;; ;;
xterm|xfce4-terminal|gnome-terminal|terminal|termite) xterm|xfce4-terminal|gnome-terminal|terminal|termite|mate-terminal)
$LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'" $LAUNCHTERM -e "sh -c 'TERM=xterm $LAUNCHER'"
;; ;;
konsole) konsole)

View File

@ -46,12 +46,16 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, () => new Pan()); yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 2, () => new Pan());
yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, () => new Wand()); yield return this.TryCreate(ItemType.Tool, this.CustomIDOffset + 3, () => new Wand());
// clothing
foreach (int id in Game1.clothingInformation.Keys)
yield return this.TryCreate(ItemType.Clothing, id, () => new Clothing(id));
// wallpapers // wallpapers
for (int id = 0; id < 112; id++) for (int id = 0; id < 112; id++)
yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory }); yield return this.TryCreate(ItemType.Wallpaper, id, () => new Wallpaper(id) { Category = SObject.furnitureCategory });
// flooring // flooring
for (int id = 0; id < 40; id++) for (int id = 0; id < 56; id++)
yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory }); yield return this.TryCreate(ItemType.Flooring, id, () => new Wallpaper(id, isFloor: true) { Category = SObject.furnitureCategory });
// equipment // equipment
@ -59,11 +63,6 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id)); yield return this.TryCreate(ItemType.Boots, id, () => new Boots(id));
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys) foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\hats").Keys)
yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id)); yield return this.TryCreate(ItemType.Hat, id, () => new Hat(id));
foreach (int id in Game1.objectInformation.Keys)
{
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
yield return this.TryCreate(ItemType.Ring, id, () => new Ring(id));
}
// weapons // weapons
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys) foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\weapons").Keys)
@ -87,101 +86,91 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
foreach (int id in Game1.bigCraftablesInformation.Keys) foreach (int id in Game1.bigCraftablesInformation.Keys)
yield return this.TryCreate(ItemType.BigCraftable, id, () => new SObject(Vector2.Zero, id)); yield return this.TryCreate(ItemType.BigCraftable, id, () => new SObject(Vector2.Zero, id));
// secret notes
foreach (int id in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + id, () =>
{
SObject note = new SObject(79, 1);
note.name = $"{note.name} #{id}";
return note;
});
}
// objects // objects
foreach (int id in Game1.objectInformation.Keys) foreach (int id in Game1.objectInformation.Keys)
{ {
if (id == 79) string[] fields = Game1.objectInformation[id]?.Split('/');
continue; // secret note handled above
if (id >= Ring.ringLowerIndexRange && id <= Ring.ringUpperIndexRange)
continue; // handled separated
// spawn main item // secret notes
SObject item; if (id == 79)
{ {
SearchableItem main = this.TryCreate(ItemType.Object, id, () => id == 812 foreach (int secretNoteId in Game1.content.Load<Dictionary<int, string>>("Data\\SecretNotes").Keys)
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset + secretNoteId, () =>
{
SObject note = new SObject(79, 1);
note.name = $"{note.name} #{secretNoteId}";
return note;
});
}
}
// ring
else if (id != 801 && fields?.Length >= 4 && fields[3] == "Ring") // 801 = wedding ring, which isn't an equippable ring
yield return this.TryCreate(ItemType.Ring, id, () => new Ring(id));
// item
else
{
// spawn main item
SObject item = null;
yield return this.TryCreate(ItemType.Object, id, () =>
{
return item = (id == 812 // roe
? new ColoredObject(id, 1, Color.White) ? new ColoredObject(id, 1, Color.White)
: new SObject(id, 1) : new SObject(id, 1)
); );
yield return main; });
item = main?.Item as SObject;
}
if (item == null) if (item == null)
continue; continue;
// flavored items
switch (item.Category)
{
// fruit products // fruit products
if (item.Category == SObject.FruitsCategory) case SObject.FruitsCategory:
{
// wine // wine
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () => yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 2 + id, () => new SObject(348, 1)
{
SObject wine = new SObject(348, 1)
{ {
Name = $"{item.Name} Wine", Name = $"{item.Name} Wine",
Price = item.Price * 3 Price = item.Price * 3,
}; preserve = { SObject.PreserveType.Wine },
wine.preserve.Value = SObject.PreserveType.Wine; preservedParentSheetIndex = { item.ParentSheetIndex }
wine.preservedParentSheetIndex.Value = item.ParentSheetIndex;
return wine;
}); });
// jelly // jelly
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () => yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 3 + id, () => new SObject(344, 1)
{
SObject jelly = new SObject(344, 1)
{ {
Name = $"{item.Name} Jelly", Name = $"{item.Name} Jelly",
Price = 50 + item.Price * 2 Price = 50 + item.Price * 2,
}; preserve = { SObject.PreserveType.Jelly },
jelly.preserve.Value = SObject.PreserveType.Jelly; preservedParentSheetIndex = { item.ParentSheetIndex }
jelly.preservedParentSheetIndex.Value = item.ParentSheetIndex;
return jelly;
}); });
} break;
// vegetable products // vegetable products
else if (item.Category == SObject.VegetableCategory) case SObject.VegetableCategory:
{
// juice // juice
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () => yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 4 + id, () => new SObject(350, 1)
{
SObject juice = new SObject(350, 1)
{ {
Name = $"{item.Name} Juice", Name = $"{item.Name} Juice",
Price = (int)(item.Price * 2.25d) Price = (int)(item.Price * 2.25d),
}; preserve = { SObject.PreserveType.Juice },
juice.preserve.Value = SObject.PreserveType.Juice; preservedParentSheetIndex = { item.ParentSheetIndex }
juice.preservedParentSheetIndex.Value = item.ParentSheetIndex;
return juice;
}); });
// pickled // pickled
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => new SObject(342, 1)
{
SObject pickled = new SObject(342, 1)
{ {
Name = $"Pickled {item.Name}", Name = $"Pickled {item.Name}",
Price = 50 + item.Price * 2 Price = 50 + item.Price * 2,
}; preserve = { SObject.PreserveType.Pickle },
pickled.preserve.Value = SObject.PreserveType.Pickle; preservedParentSheetIndex = { item.ParentSheetIndex }
pickled.preservedParentSheetIndex.Value = item.ParentSheetIndex;
return pickled;
}); });
} break;
// flower honey // flower honey
else if (item.Category == SObject.flowersCategory) case SObject.flowersCategory:
{
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () => yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 5 + id, () =>
{ {
SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false) SObject honey = new SObject(Vector2.Zero, 340, $"{item.Name} Honey", false, true, false, false)
@ -192,43 +181,47 @@ namespace StardewModdingAPI.Mods.ConsoleCommands.Framework
honey.Price += item.Price * 2; honey.Price += item.Price * 2;
return honey; return honey;
}); });
} break;
// roe and aged roe (derived from FishPond.GetFishProduce) // roe and aged roe (derived from FishPond.GetFishProduce)
else if (id == 812) case SObject.sellAtFishShopCategory when id == 812:
{
foreach (var pair in Game1.objectInformation) foreach (var pair in Game1.objectInformation)
{ {
// get input // get input
SObject input = new SObject(pair.Key, 1); SObject input = this.TryCreate(ItemType.Object, -1, () => new SObject(pair.Key, 1))?.Item as SObject;
if (input.Category != SObject.FishCategory) if (input == null || input.Category != SObject.FishCategory)
continue; continue;
Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange; Color color = TailoringMenu.GetDyeColor(input) ?? Color.Orange;
// yield roe // yield roe
SObject roe = new ColoredObject(812, 1, color) SObject roe = null;
yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () =>
{
roe = new ColoredObject(812, 1, color)
{ {
name = $"{input.Name} Roe", name = $"{input.Name} Roe",
preserve = { Value = SObject.PreserveType.Roe }, preserve = { Value = SObject.PreserveType.Roe },
preservedParentSheetIndex = { Value = input.ParentSheetIndex } preservedParentSheetIndex = { Value = input.ParentSheetIndex }
}; };
roe.Price += input.Price / 2; roe.Price += input.Price / 2;
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, roe); return roe;
});
// aged roe // aged roe
if (pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item if (roe != null && pair.Key != 698) // aged sturgeon roe is caviar, which is a separate item
{ {
ColoredObject agedRoe = new ColoredObject(447, 1, color) yield return this.TryCreate(ItemType.Object, this.CustomIDOffset * 7 + id, () => new ColoredObject(447, 1, color)
{ {
name = $"Aged {input.Name} Roe", name = $"Aged {input.Name} Roe",
Category = -27, Category = -27,
preserve = { Value = SObject.PreserveType.AgedRoe }, preserve = { Value = SObject.PreserveType.AgedRoe },
preservedParentSheetIndex = { Value = input.ParentSheetIndex }, preservedParentSheetIndex = { Value = input.ParentSheetIndex },
Price = roe.Price * 2 Price = roe.Price * 2
}; });
yield return new SearchableItem(ItemType.Object, this.CustomIDOffset * 6 + 1, agedRoe);
} }
} }
break;
}
} }
} }
} }

View File

@ -1,9 +1,9 @@
{ {
"Name": "Console Commands", "Name": "Console Commands",
"Author": "SMAPI", "Author": "SMAPI",
"Version": "3.0.1", "Version": "3.1.0",
"Description": "Adds SMAPI console commands that let you manipulate the game.", "Description": "Adds SMAPI console commands that let you manipulate the game.",
"UniqueID": "SMAPI.ConsoleCommands", "UniqueID": "SMAPI.ConsoleCommands",
"EntryDll": "ConsoleCommands.dll", "EntryDll": "ConsoleCommands.dll",
"MinimumApiVersion": "3.0.1" "MinimumApiVersion": "3.1.0"
} }

View File

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

View File

@ -27,10 +27,13 @@ namespace StardewModdingAPI.Toolkit.Framework.ModData
/// <summary>The mod has no update keys set.</summary> /// <summary>The mod has no update keys set.</summary>
NoUpdateKeys = 32, NoUpdateKeys = 32,
/// <summary>Uses .NET APIs for reading and writing to the console.</summary>
AccessesConsole = 64,
/// <summary>Uses .NET APIs for filesystem access.</summary> /// <summary>Uses .NET APIs for filesystem access.</summary>
AccessesFilesystem = 64, AccessesFilesystem = 128,
/// <summary>Uses .NET APIs for shell or process access.</summary> /// <summary>Uses .NET APIs for shell or process access.</summary>
AccessesShell = 128 AccessesShell = 256
} }
} }

View File

@ -105,25 +105,29 @@ namespace StardewModdingAPI.Toolkit.Utilities
/// </remarks> /// </remarks>
private static bool IsRunningAndroid() private static bool IsRunningAndroid()
{ {
using (Process process = new Process()) using Process process = new Process
{ {
process.StartInfo.FileName = "getprop"; StartInfo =
process.StartInfo.Arguments = "ro.build.user"; {
process.StartInfo.RedirectStandardOutput = true; FileName = "getprop",
process.StartInfo.UseShellExecute = false; Arguments = "ro.build.user",
process.StartInfo.CreateNoWindow = true; RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
}
};
try try
{ {
process.Start(); process.Start();
string output = process.StandardOutput.ReadToEnd(); string output = process.StandardOutput.ReadToEnd();
return !string.IsNullOrEmpty(output); return !string.IsNullOrWhiteSpace(output);
} }
catch catch
{ {
return false; return false;
} }
} }
}
/// <summary>Detect whether the code is running on Mac.</summary> /// <summary>Detect whether the code is running on Mac.</summary>
/// <remarks> /// <remarks>

View File

@ -16,7 +16,6 @@ namespace StardewModdingAPI.Web.Controllers
{ {
/// <summary>Provides an info/download page about SMAPI.</summary> /// <summary>Provides an info/download page about SMAPI.</summary>
[Route("")] [Route("")]
[Route("install")]
internal class IndexController : Controller internal class IndexController : Controller
{ {
/********* /*********
@ -72,7 +71,7 @@ namespace StardewModdingAPI.Web.Controllers
: null; : null;
// render view // render view
var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb); var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb, this.SiteConfig.SupporterList);
return this.View(model); return this.View(model);
} }

View File

@ -9,8 +9,7 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema; using Newtonsoft.Json.Schema;
using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Clients.Pastebin; using StardewModdingAPI.Web.Framework.Storage;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.ViewModels.JsonValidator; using StardewModdingAPI.Web.ViewModels.JsonValidator;
namespace StardewModdingAPI.Web.Controllers namespace StardewModdingAPI.Web.Controllers
@ -21,11 +20,8 @@ namespace StardewModdingAPI.Web.Controllers
/********* /*********
** Fields ** Fields
*********/ *********/
/// <summary>The underlying Pastebin client.</summary> /// <summary>Provides access to raw data storage.</summary>
private readonly IPastebinClient Pastebin; private readonly IStorageProvider Storage;
/// <summary>The underlying text compression helper.</summary>
private readonly IGzipHelper GzipHelper;
/// <summary>The supported JSON schemas (names indexed by ID).</summary> /// <summary>The supported JSON schemas (names indexed by ID).</summary>
private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string> private readonly IDictionary<string, string> SchemaFormats = new Dictionary<string, string>
@ -49,20 +45,18 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor ** Constructor
***/ ***/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="pastebin">The Pastebin API client.</param> /// <param name="storage">Provides access to raw data storage.</param>
/// <param name="gzipHelper">The underlying text compression helper.</param> public JsonValidatorController(IStorageProvider storage)
public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper)
{ {
this.Pastebin = pastebin; this.Storage = storage;
this.GzipHelper = gzipHelper;
} }
/*** /***
** Web UI ** Web UI
***/ ***/
/// <summary>Render the schema validator UI.</summary> /// <summary>Render the schema validator UI.</summary>
/// <param name="schemaName">The schema name with which to validate the JSON.</param> /// <param name="schemaName">The schema name with which to validate the JSON, or 'edit' to return to the edit screen.</param>
/// <param name="id">The paste ID.</param> /// <param name="id">The stored file ID.</param>
[HttpGet] [HttpGet]
[Route("json")] [Route("json")]
[Route("json/{schemaName}")] [Route("json/{schemaName}")]
@ -76,16 +70,20 @@ namespace StardewModdingAPI.Web.Controllers
return this.View("Index", result); return this.View("Index", result);
// fetch raw JSON // fetch raw JSON
PasteInfo paste = await this.GetAsync(id); StoredFileInfo file = await this.Storage.GetAsync(id);
if (string.IsNullOrWhiteSpace(paste.Content)) if (string.IsNullOrWhiteSpace(file.Content))
return this.View("Index", result.SetUploadError("The JSON file seems to be empty.")); return this.View("Index", result.SetUploadError("The JSON file seems to be empty."));
result.SetContent(paste.Content); result.SetContent(file.Content, expiry: file.Expiry, uploadWarning: file.Warning);
// skip parsing if we're going to the edit screen
if (schemaName?.ToLower() == "edit")
return this.View("Index", result);
// parse JSON // parse JSON
JToken parsed; JToken parsed;
try try
{ {
parsed = JToken.Parse(paste.Content, new JsonLoadSettings parsed = JToken.Parse(file.Content, new JsonLoadSettings
{ {
DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error, DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error,
CommentHandling = CommentHandling.Load CommentHandling = CommentHandling.Load
@ -97,7 +95,7 @@ namespace StardewModdingAPI.Web.Controllers
} }
// format JSON // format JSON
result.SetContent(parsed.ToString(Formatting.Indented)); result.SetContent(parsed.ToString(Formatting.Indented), expiry: file.Expiry, uploadWarning: file.Warning);
// skip if no schema selected // skip if no schema selected
if (schemaName == "none") if (schemaName == "none")
@ -132,23 +130,20 @@ namespace StardewModdingAPI.Web.Controllers
public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request) public async Task<ActionResult> PostAsync(JsonValidatorRequestModel request)
{ {
if (request == null) if (request == null)
return this.View("Index", new JsonValidatorModel(null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid."));
// normalize schema name // normalize schema name
string schemaName = this.NormalizeSchemaName(request.SchemaName); string schemaName = this.NormalizeSchemaName(request.SchemaName);
// get raw log text // get raw text
string input = request.Content; string input = request.Content;
if (string.IsNullOrWhiteSpace(input)) if (string.IsNullOrWhiteSpace(input))
return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty."));
// upload log // upload file
input = this.GzipHelper.CompressString(input); UploadResult result = await this.Storage.SaveAsync(input);
SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input); if (!result.Succeeded)
return this.View("Index", this.GetModel(result.ID, schemaName).SetUploadError(result.UploadError));
// handle errors
if (!result.Success)
return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}"));
// redirect to view // redirect to view
return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID })); return this.Redirect(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = schemaName, id = result.ID }));
@ -158,13 +153,12 @@ namespace StardewModdingAPI.Web.Controllers
/********* /*********
** Private methods ** Private methods
*********/ *********/
/// <summary>Fetch raw text from Pastebin.</summary> /// <summary>Build a JSON validator model.</summary>
/// <param name="id">The Pastebin paste ID.</param> /// <param name="pasteID">The stored file ID.</param>
private async Task<PasteInfo> GetAsync(string id) /// <param name="schemaName">The schema name with which the JSON was validated.</param>
private JsonValidatorModel GetModel(string pasteID, string schemaName)
{ {
PasteInfo response = await this.Pastebin.GetAsync(id); return new JsonValidatorModel(pasteID, schemaName, this.SchemaFormats);
response.Content = this.GzipHelper.DecompressString(response.Content);
return response;
} }
/// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary> /// <summary>Get a normalized schema name, or the <see cref="DefaultSchemaID"/> if blank.</summary>

View File

@ -1,22 +1,12 @@
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Amazon;
using Amazon.Runtime;
using Amazon.S3;
using Amazon.S3.Model;
using Amazon.S3.Transfer;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities;
using StardewModdingAPI.Web.Framework; using StardewModdingAPI.Web.Framework;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing;
using StardewModdingAPI.Web.Framework.LogParsing.Models; using StardewModdingAPI.Web.Framework.LogParsing.Models;
using StardewModdingAPI.Web.Framework.Storage;
using StardewModdingAPI.Web.ViewModels; using StardewModdingAPI.Web.ViewModels;
namespace StardewModdingAPI.Web.Controllers namespace StardewModdingAPI.Web.Controllers
@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers
/********* /*********
** Fields ** Fields
*********/ *********/
/// <summary>The API client settings.</summary> /// <summary>Provides access to raw data storage.</summary>
private readonly ApiClientsConfig ClientsConfig; private readonly IStorageProvider Storage;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
/// <summary>The underlying text compression helper.</summary>
private readonly IGzipHelper GzipHelper;
/********* /*********
@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers
** Constructor ** Constructor
***/ ***/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="clientsConfig">The API client settings.</param> /// <param name="storage">Provides access to raw data storage.</param>
/// <param name="pastebin">The Pastebin API client.</param> public LogParserController(IStorageProvider storage)
/// <param name="gzipHelper">The underlying text compression helper.</param>
public LogParserController(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
{ {
this.ClientsConfig = clientsConfig.Value; this.Storage = storage;
this.Pastebin = pastebin;
this.GzipHelper = gzipHelper;
} }
/*** /***
** Web UI ** Web UI
***/ ***/
/// <summary>Render the log parser UI.</summary> /// <summary>Render the log parser UI.</summary>
/// <param name="id">The paste ID.</param> /// <param name="id">The stored file ID.</param>
/// <param name="raw">Whether to display the raw unparsed log.</param> /// <param name="raw">Whether to display the raw unparsed log.</param>
[HttpGet] [HttpGet]
[Route("log")] [Route("log")]
@ -70,12 +50,12 @@ namespace StardewModdingAPI.Web.Controllers
return this.View("Index", this.GetModel(id)); return this.View("Index", this.GetModel(id));
// log page // log page
PasteInfo paste = await this.GetAsync(id); StoredFileInfo file = await this.Storage.GetAsync(id);
ParsedLog log = paste.Success ParsedLog log = file.Success
? new LogParser().Parse(paste.Content) ? new LogParser().Parse(file.Content)
: new ParsedLog { IsValid = false, Error = paste.Error }; : new ParsedLog { IsValid = false, Error = file.Error };
return this.View("Index", this.GetModel(id, uploadWarning: paste.Warning, expiry: paste.Expiry).SetResult(log, raw)); return this.View("Index", this.GetModel(id, uploadWarning: file.Warning, expiry: file.Expiry).SetResult(log, raw));
} }
/*** /***
@ -92,8 +72,7 @@ namespace StardewModdingAPI.Web.Controllers
return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty.")); return this.View("Index", this.GetModel(null, uploadError: "The log file seems to be empty."));
// upload log // upload log
input = this.GzipHelper.CompressString(input); UploadResult uploadResult = await this.Storage.SaveAsync(input);
var uploadResult = await this.TrySaveLog(input);
if (!uploadResult.Succeeded) if (!uploadResult.Succeeded)
return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError));
@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers
/********* /*********
** Private methods ** Private methods
*********/ *********/
/// <summary>Fetch raw text from Pastebin.</summary>
/// <param name="id">The Pastebin paste ID.</param>
private async Task<PasteInfo> GetAsync(string id)
{
// get from Amazon S3
if (Guid.TryParseExact(id, "N", out Guid _))
{
var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
{
try
{
using (GetObjectResponse response = await s3.GetObjectAsync(this.ClientsConfig.AmazonLogBucket, $"logs/{id}"))
using (Stream responseStream = response.ResponseStream)
using (StreamReader reader = new StreamReader(responseStream))
{
DateTime expiry = response.Expiration.ExpiryDateUtc;
string pastebinError = response.Metadata["x-amz-meta-pastebin-error"];
string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
return new PasteInfo
{
Success = true,
Content = content,
Expiry = expiry,
Warning = pastebinError
};
}
}
catch (AmazonServiceException ex)
{
return ex.ErrorCode == "NoSuchKey"
? new PasteInfo { Error = "There's no log with that ID." }
: new PasteInfo { Error = $"Could not fetch that log from AWS S3 ({ex.ErrorCode}: {ex.Message})." };
}
}
}
// get from PasteBin
else
{
PasteInfo response = await this.Pastebin.GetAsync(id);
response.Content = this.GzipHelper.DecompressString(response.Content);
return response;
}
}
/// <summary>Save a log to Pastebin or Amazon S3, if available.</summary>
/// <param name="content">The content to upload.</param>
/// <returns>Returns metadata about the save attempt.</returns>
private async Task<UploadResult> TrySaveLog(string content)
{
// save to PasteBin
string uploadError;
{
SavePasteResult result = await this.Pastebin.PostAsync($"SMAPI log {DateTime.UtcNow:s}", content);
if (result.Success)
return new UploadResult(true, result.ID, null);
uploadError = $"Pastebin error: {result.Error ?? "unknown error"}";
}
// fallback to S3
try
{
var credentials = new BasicAWSCredentials(accessKey: this.ClientsConfig.AmazonAccessKey, secretKey: this.ClientsConfig.AmazonSecretKey);
using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content)))
using (IAmazonS3 s3 = new AmazonS3Client(credentials, RegionEndpoint.GetBySystemName(this.ClientsConfig.AmazonRegion)))
using (TransferUtility uploader = new TransferUtility(s3))
{
string id = Guid.NewGuid().ToString("N");
var uploadRequest = new TransferUtilityUploadRequest
{
BucketName = this.ClientsConfig.AmazonLogBucket,
Key = $"logs/{id}",
InputStream = stream,
Metadata =
{
// note: AWS will lowercase keys and prefix 'x-amz-meta-'
["smapi-uploaded"] = DateTime.UtcNow.ToString("O"),
["pastebin-error"] = uploadError
}
};
await uploader.UploadAsync(uploadRequest);
return new UploadResult(true, id, uploadError);
}
}
catch (Exception ex)
{
return new UploadResult(false, null, $"{uploadError}\n{ex.Message}");
}
}
/// <summary>Build a log parser model.</summary> /// <summary>Build a log parser model.</summary>
/// <param name="pasteID">The paste ID.</param> /// <param name="pasteID">The stored file ID.</param>
/// <param name="expiry">When the uploaded file will no longer be available.</param> /// <param name="expiry">When the uploaded file will no longer be available.</param>
/// <param name="uploadWarning">A non-blocking warning while uploading the log.</param> /// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
/// <param name="uploadError">An error which occurred while uploading the log.</param> /// <param name="uploadError">An error which occurred while uploading the log.</param>
@ -243,36 +124,5 @@ namespace StardewModdingAPI.Web.Controllers
return null; return null;
} }
} }
/// <summary>The result of an attempt to upload a file.</summary>
private class UploadResult
{
/*********
** Accessors
*********/
/// <summary>Whether the file upload succeeded.</summary>
public bool Succeeded { get; }
/// <summary>The file ID, if applicable.</summary>
public string ID { get; }
/// <summary>The upload error, if any.</summary>
public string UploadError { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="succeeded">Whether the file upload succeeded.</param>
/// <param name="id">The file ID, if applicable.</param>
/// <param name="uploadError">The upload error, if any.</param>
public UploadResult(bool succeeded, string id, string uploadError)
{
this.Succeeded = succeeded;
this.ID = id;
this.UploadError = uploadError;
}
}
} }
} }

View File

@ -9,10 +9,5 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>Fetch a saved paste.</summary> /// <summary>Fetch a saved paste.</summary>
/// <param name="id">The paste ID.</param> /// <param name="id">The paste ID.</param>
Task<PasteInfo> GetAsync(string id); Task<PasteInfo> GetAsync(string id);
/// <summary>Save a paste to Pastebin.</summary>
/// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
Task<SavePasteResult> PostAsync(string name, string content);
} }
} }

View File

@ -1,5 +1,3 @@
using System;
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{ {
/// <summary>The response for a get-paste request.</summary> /// <summary>The response for a get-paste request.</summary>
@ -11,12 +9,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary> /// <summary>The fetched paste content (if <see cref="Success"/> is <c>true</c>).</summary>
public string Content { get; set; } public string Content { get; set; }
/// <summary>When the file will no longer be available.</summary>
public DateTime? Expiry { get; set; }
/// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary>
public string Warning { get; set; }
/// <summary>The error message if saving failed.</summary> /// <summary>The error message if saving failed.</summary>
public string Error { get; set; } public string Error { get; set; }
} }

View File

@ -1,7 +1,5 @@
using System; using System;
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Pathoschild.Http.Client; using Pathoschild.Http.Client;
@ -16,12 +14,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>The underlying HTTP client.</summary> /// <summary>The underlying HTTP client.</summary>
private readonly IClient Client; private readonly IClient Client;
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
private readonly string UserKey;
/// <summary>The developer key used to authenticate with the Pastebin API.</summary>
private readonly string DevKey;
/********* /*********
** Public methods ** Public methods
@ -29,13 +21,9 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="baseUrl">The base URL for the Pastebin API.</param> /// <param name="baseUrl">The base URL for the Pastebin API.</param>
/// <param name="userAgent">The user agent for the API client.</param> /// <param name="userAgent">The user agent for the API client.</param>
/// <param name="userKey">The user key used to authenticate with the Pastebin API.</param> public PastebinClient(string baseUrl, string userAgent)
/// <param name="devKey">The developer key used to authenticate with the Pastebin API.</param>
public PastebinClient(string baseUrl, string userAgent, string userKey, string devKey)
{ {
this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent); this.Client = new FluentClient(baseUrl).SetUserAgent(userAgent);
this.UserKey = userKey;
this.DevKey = devKey;
} }
/// <summary>Fetch a saved paste.</summary> /// <summary>Fetch a saved paste.</summary>
@ -66,50 +54,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
} }
} }
/// <summary>Save a paste to Pastebin.</summary>
/// <param name="name">The paste name.</param>
/// <param name="content">The paste content.</param>
public async Task<SavePasteResult> PostAsync(string name, string content)
{
try
{
// validate
if (string.IsNullOrWhiteSpace(content))
return new SavePasteResult { Error = "The log content can't be empty." };
// post to API
string response = await this.Client
.PostAsync("api/api_post.php")
.WithBody(p => p.FormUrlEncoded(new
{
api_option = "paste",
api_user_key = this.UserKey,
api_dev_key = this.DevKey,
api_paste_private = 1, // unlisted
api_paste_name = name,
api_paste_expire_date = "N", // never expire
api_paste_code = content
}))
.AsString();
// handle Pastebin errors
if (string.IsNullOrWhiteSpace(response))
return new SavePasteResult { Error = "Received an empty response from Pastebin." };
if (response.StartsWith("Bad API request"))
return new SavePasteResult { Error = response };
if (!response.Contains("/"))
return new SavePasteResult { Error = $"Received an unknown response: {response}" };
// return paste ID
string pastebinID = response.Split("/").Last();
return new SavePasteResult { Success = true, ID = pastebinID };
}
catch (Exception ex)
{
return new SavePasteResult { Success = false, Error = ex.ToString() };
}
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary> /// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
public void Dispose() public void Dispose()
{ {

View File

@ -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; }
}
}

View File

@ -14,19 +14,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/**** /****
** Amazon Web Services ** Azure
****/ ****/
/// <summary>The access key for AWS authentication.</summary> /// <summary>The connection string for the Azure Blob storage account.</summary>
public string AmazonAccessKey { get; set; } public string AzureBlobConnectionString { get; set; }
/// <summary>The secret key for AWS authentication.</summary> /// <summary>The Azure Blob container in which to store temporary uploaded logs.</summary>
public string AmazonSecretKey { get; set; } public string AzureBlobTempContainer { get; set; }
/// <summary>The AWS region endpoint (like 'us-east-1').</summary> /// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary>
public string AmazonRegion { get; set; } public int AzureBlobTempExpiryDays { get; set; }
/// <summary>The AWS bucket in which to store temporary uploaded logs.</summary>
public string AmazonLogBucket { get; set; }
/**** /****
@ -61,6 +58,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The password with which to authenticate to the GitHub API (if any).</summary> /// <summary>The password with which to authenticate to the GitHub API (if any).</summary>
public string GitHubPassword { get; set; } public string GitHubPassword { get; set; }
/**** /****
** ModDrop ** ModDrop
****/ ****/
@ -70,6 +68,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary> /// <summary>The URL for a ModDrop mod page for the user, where {0} is the mod ID.</summary>
public string ModDropModPageUrl { get; set; } public string ModDropModPageUrl { get; set; }
/**** /****
** Nexus Mods ** Nexus Mods
****/ ****/
@ -85,17 +84,11 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>The Nexus API authentication key.</summary> /// <summary>The Nexus API authentication key.</summary>
public string NexusApiKey { get; set; } public string NexusApiKey { get; set; }
/**** /****
** Pastebin ** Pastebin
****/ ****/
/// <summary>The base URL for the Pastebin API.</summary> /// <summary>The base URL for the Pastebin API.</summary>
public string PastebinBaseUrl { get; set; } public string PastebinBaseUrl { get; set; }
/// <summary>The user key used to authenticate with the Pastebin API.</summary>
public string PastebinUserKey { get; set; }
/// <summary>The developer key used to authenticate with the Pastebin API.</summary>
public string PastebinDevKey { get; set; }
} }
} }

View File

@ -1,5 +1,3 @@
using System;
namespace StardewModdingAPI.Web.Framework.ConfigModels namespace StardewModdingAPI.Web.Framework.ConfigModels
{ {
/// <summary>The config settings for mod compatibility list.</summary> /// <summary>The config settings for mod compatibility list.</summary>
@ -8,14 +6,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/********* /*********
** Accessors ** Accessors
*********/ *********/
/// <summary>The MongoDB hostname.</summary> /// <summary>The MongoDB connection string.</summary>
public string Host { get; set; } public string ConnectionString { get; set; }
/// <summary>The MongoDB username (if any).</summary>
public string Username { get; set; }
/// <summary>The MongoDB password (if any).</summary>
public string Password { get; set; }
/// <summary>The database name.</summary> /// <summary>The database name.</summary>
public string Database { get; set; } public string Database { get; set; }
@ -24,15 +16,10 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/********* /*********
** Public method ** Public method
*********/ *********/
/// <summary>Get the MongoDB connection string.</summary> /// <summary>Get whether a MongoDB instance is configured.</summary>
public string GetConnectionString() public bool IsConfigured()
{ {
bool isLocal = this.Host == "localhost"; return !string.IsNullOrWhiteSpace(this.ConnectionString);
bool hasLogin = !string.IsNullOrWhiteSpace(this.Username) && !string.IsNullOrWhiteSpace(this.Password);
return $"mongodb{(isLocal ? "" : "+srv")}://"
+ (hasLogin ? $"{Uri.EscapeDataString(this.Username)}:{Uri.EscapeDataString(this.Password)}@" : "")
+ $"{this.Host}/{this.Database}?retryWrites=true&w=majority";
} }
} }
} }

View File

@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/// <summary>A short sentence shown under the beta download button, if any.</summary> /// <summary>A short sentence shown under the beta download button, if any.</summary>
public string BetaBlurb { get; set; } public string BetaBlurb { get; set; }
/// <summary>A list of supports to credit on the main page, in Markdown format.</summary>
public string SupporterList { get; set; }
} }
} }

View File

@ -1,4 +1,6 @@
using System;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
@ -12,8 +14,9 @@ namespace StardewModdingAPI.Web.Framework
/// <param name="action">The name of the action method.</param> /// <param name="action">The name of the action method.</param>
/// <param name="controller">The name of the controller.</param> /// <param name="controller">The name of the controller.</param>
/// <param name="values">An object that contains route values.</param> /// <param name="values">An object that contains route values.</param>
/// <param name="absoluteUrl">Get an absolute URL instead of a server-relative path/</param>
/// <returns>The generated URL.</returns> /// <returns>The generated URL.</returns>
public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null) public static string PlainAction(this IUrlHelper helper, [AspMvcAction] string action, [AspMvcController] string controller, object values = null, bool absoluteUrl = false)
{ {
RouteValueDictionary valuesDict = new RouteValueDictionary(values); RouteValueDictionary valuesDict = new RouteValueDictionary(values);
foreach (var value in helper.ActionContext.RouteData.Values) foreach (var value in helper.ActionContext.RouteData.Values)
@ -22,7 +25,14 @@ namespace StardewModdingAPI.Web.Framework
valuesDict[value.Key] = null; // explicitly remove it from the URL valuesDict[value.Key] = null; // explicitly remove it from the URL
} }
return helper.Action(action, controller, valuesDict); string url = helper.Action(action, controller, valuesDict);
if (absoluteUrl)
{
HttpRequest request = helper.ActionContext.HttpContext.Request;
Uri baseUri = new Uri($"{request.Scheme}://{request.Host}");
url = new Uri(baseUri, url).ToString();
}
return url;
} }
} }
} }

View File

@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary> /// <summary>A regex pattern matching an entry in SMAPI's content pack list.</summary>
private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+) (?<version>.+) by (?<author>.+) \| for (?<for>.+?)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly Regex ContentPackListEntryPattern = new Regex(@"^ (?<name>.+?) (?<version>" + SemanticVersion.UnboundedVersionPattern + @")(?: by (?<author>[^\|]+))? \| for (?<for>[^\|]+)(?: \| (?<description>.+))?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary> /// <summary>A regex pattern matching the start of SMAPI's mod update list.</summary>
private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly Regex ModUpdateListStartPattern = new Regex(@"^You can update \d+ mods?:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);

View File

@ -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);
}
}

View File

@ -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");
}
}
}

View File

@ -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; }
}
}

View File

@ -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;
}
}
}

View File

@ -12,8 +12,9 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.3.108.4" /> <PackageReference Include="Azure.Storage.Blobs" Version="12.1.0" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" /> <PackageReference Include="Hangfire.AspNetCore" Version="1.7.7" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.6.3" />
<PackageReference Include="Hangfire.Mongo" Version="0.6.5" /> <PackageReference Include="Hangfire.Mongo" Version="0.6.5" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.16" /> <PackageReference Include="HtmlAgilityPack" Version="1.11.16" />
<PackageReference Include="Humanizer.Core" Version="2.7.9" /> <PackageReference Include="Humanizer.Core" Version="2.7.9" />
@ -23,6 +24,7 @@
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Rewrite" Version="2.2.0" />
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.2.0" />
<PackageReference Include="Mongo2Go" Version="2.2.12" />
<PackageReference Include="MongoDB.Driver" Version="2.9.3" /> <PackageReference Include="MongoDB.Driver" Version="2.9.3" />
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" /> <PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.11" />
<PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" /> <PackageReference Include="Pathoschild.FluentNexus" Version="0.8.0" />

View File

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Hangfire; using Hangfire;
using Hangfire.MemoryStorage;
using Hangfire.Mongo; using Hangfire.Mongo;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
@ -7,6 +9,8 @@ using Microsoft.AspNetCore.Rewrite;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Mongo2Go;
using MongoDB.Bson.Serialization; using MongoDB.Bson.Serialization;
using MongoDB.Driver; using MongoDB.Driver;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -24,6 +28,7 @@ using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression; using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.ConfigModels;
using StardewModdingAPI.Web.Framework.RewriteRules; using StardewModdingAPI.Web.Framework.RewriteRules;
using StardewModdingAPI.Web.Framework.Storage;
namespace StardewModdingAPI.Web namespace StardewModdingAPI.Web
{ {
@ -87,10 +92,20 @@ namespace StardewModdingAPI.Web
} }
// init MongoDB // init MongoDB
services.AddSingleton<MongoDbRunner>(serv => !mongoConfig.IsConfigured()
? MongoDbRunner.Start()
: throw new InvalidOperationException("The MongoDB connection is configured, so the local development version should not be used.")
);
services.AddSingleton<IMongoDatabase>(serv => services.AddSingleton<IMongoDatabase>(serv =>
{ {
// get connection string
string connectionString = mongoConfig.IsConfigured()
? mongoConfig.ConnectionString
: serv.GetRequiredService<MongoDbRunner>().ConnectionString;
// get client
BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer()); BsonSerializer.RegisterSerializer(new UtcDateTimeOffsetSerializer());
return new MongoClient(mongoConfig.GetConnectionString()).GetDatabase(mongoConfig.Database); return new MongoClient(connectionString).GetDatabase(mongoConfig.Database);
}); });
services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>())); services.AddSingleton<IModCacheRepository>(serv => new ModCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>())); services.AddSingleton<IWikiCacheRepository>(serv => new WikiCacheRepository(serv.GetRequiredService<IMongoDatabase>()));
@ -102,12 +117,18 @@ namespace StardewModdingAPI.Web
config config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .SetDataCompatibilityLevel(CompatibilityLevel.Version_170)
.UseSimpleAssemblyNameTypeSerializer() .UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings() .UseRecommendedSerializerSettings();
.UseMongoStorage(mongoConfig.GetConnectionString(), $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
if (mongoConfig.IsConfigured())
{
config.UseMongoStorage(mongoConfig.ConnectionString, $"{mongoConfig.Database}-hangfire", new MongoStorageOptions
{ {
MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop), MigrationOptions = new MongoMigrationOptions(MongoMigrationStrategy.Drop),
CheckConnection = false // error on startup takes down entire process CheckConnection = false // error on startup takes down entire process
}); });
}
else
config.UseMemoryStorage();
}); });
// init API clients // init API clients
@ -151,14 +172,18 @@ namespace StardewModdingAPI.Web
services.AddSingleton<IPastebinClient>(new PastebinClient( services.AddSingleton<IPastebinClient>(new PastebinClient(
baseUrl: api.PastebinBaseUrl, baseUrl: api.PastebinBaseUrl,
userAgent: userAgent, userAgent: userAgent
userKey: api.PastebinUserKey,
devKey: api.PastebinDevKey
)); ));
} }
// init helpers // init helpers
services.AddSingleton<IGzipHelper>(new GzipHelper()); services
.AddSingleton<IGzipHelper>(new GzipHelper())
.AddSingleton<IStorageProvider>(serv => new StorageProvider(
serv.GetRequiredService<IOptions<ApiClientsConfig>>(),
serv.GetRequiredService<IPastebinClient>(),
serv.GetRequiredService<IGzipHelper>()
));
} }
/// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary> /// <summary>The method called by the runtime to configure the HTTP request pipeline.</summary>

View File

@ -15,6 +15,9 @@ namespace StardewModdingAPI.Web.ViewModels
/// <summary>A short sentence shown under the beta download button, if any.</summary> /// <summary>A short sentence shown under the beta download button, if any.</summary>
public string BetaBlurb { get; set; } public string BetaBlurb { get; set; }
/// <summary>A list of supports to credit on the main page, in Markdown format.</summary>
public string SupporterList { get; set; }
/********* /*********
** Public methods ** Public methods
@ -26,11 +29,13 @@ namespace StardewModdingAPI.Web.ViewModels
/// <param name="stableVersion">The latest stable SMAPI version.</param> /// <param name="stableVersion">The latest stable SMAPI version.</param>
/// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param> /// <param name="betaVersion">The latest prerelease SMAPI version (if newer than <paramref name="stableVersion"/>).</param>
/// <param name="betaBlurb">A short sentence shown under the beta download button, if any.</param> /// <param name="betaBlurb">A short sentence shown under the beta download button, if any.</param>
internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb) /// <param name="supporterList">A list of supports to credit on the main page, in Markdown format.</param>
internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb, string supporterList)
{ {
this.StableVersion = stableVersion; this.StableVersion = stableVersion;
this.BetaVersion = betaVersion; this.BetaVersion = betaVersion;
this.BetaBlurb = betaBlurb; this.BetaBlurb = betaBlurb;
this.SupporterList = supporterList;
} }
} }
} }

View File

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@ -24,7 +25,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/// <summary>The schema validation errors, if any.</summary> /// <summary>The schema validation errors, if any.</summary>
public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0];
/// <summary>An error which occurred while uploading the JSON to Pastebin.</summary> /// <summary>A non-blocking warning while uploading the file.</summary>
public string UploadWarning { get; set; }
/// <summary>When the uploaded file will no longer be available.</summary>
public DateTime? Expiry { get; set; }
/// <summary>An error which occurred while uploading the JSON.</summary>
public string UploadError { get; set; } public string UploadError { get; set; }
/// <summary>An error which occurred while parsing the JSON.</summary> /// <summary>An error which occurred while parsing the JSON.</summary>
@ -41,7 +48,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
public JsonValidatorModel() { } public JsonValidatorModel() { }
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="pasteID">The paste ID.</param> /// <param name="pasteID">The stored file ID.</param>
/// <param name="schemaName">The schema name with which the JSON was validated.</param> /// <param name="schemaName">The schema name with which the JSON was validated.</param>
/// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param> /// <param name="schemaFormats">The supported JSON schemas (names indexed by ID).</param>
public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats) public JsonValidatorModel(string pasteID, string schemaName, IDictionary<string, string> schemaFormats)
@ -53,14 +60,18 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator
/// <summary>Set the validated content.</summary> /// <summary>Set the validated content.</summary>
/// <param name="content">The validated content.</param> /// <param name="content">The validated content.</param>
public JsonValidatorModel SetContent(string content) /// <param name="expiry">When the uploaded file will no longer be available.</param>
/// <param name="uploadWarning">A non-blocking warning while uploading the log.</param>
public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null)
{ {
this.Content = content; this.Content = content;
this.Expiry = expiry;
this.UploadWarning = uploadWarning;
return this; return this;
} }
/// <summary>Set the error which occurred while uploading the log to Pastebin.</summary> /// <summary>Set the error which occurred while uploading the JSON.</summary>
/// <param name="error">The error message.</param> /// <param name="error">The error message.</param>
public JsonValidatorModel SetUploadError(string error) public JsonValidatorModel SetUploadError(string error)
{ {

View File

@ -1,3 +1,4 @@
@using Markdig
@using Microsoft.Extensions.Options @using Microsoft.Extensions.Options
@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.Framework.ConfigModels @using StardewModdingAPI.Web.Framework.ConfigModels
@ -94,29 +95,22 @@ else
</li> </li>
<li> <li>
<a href="https://ko-fi.com/pathoschild" class="donate-button"> <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> </a>
</li> </li>
<li> <li>
<a href="https://www.paypal.me/pathoschild" class="donate-button"> <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> </a>
</li> </li>
</ul> </ul>
<p> @if (!string.IsNullOrWhiteSpace(Model.SupporterList))
Special thanks to {
<a href="https://www.nexusmods.com/users/65566526?tab=user+files">bwdy</a>, @Html.Raw(Markdig.Markdown.ToHtml(
hawkfalcon, $"Special thanks to {Model.SupporterList}, and a few anonymous users for their ongoing support on Patreon; you're awesome!"
<a href="https://twitter.com/iKeychain">iKeychain</a>, ))
jwdred, }
<a href="https://www.nexusmods.com/users/12252523">Karmylla</a>,
<a href="https://www.nexusmods.com/stardewvalley/users/51777556">minervamaga</a>,
Pucklynn,
Renorien,
Robby LaFarge,
and a few anonymous users for their ongoing support on Patreon; you're awesome!
</p>
<h2 id="modcreators">For mod creators</h2> <h2 id="modcreators">For mod creators</h2>
<ul> <ul>

View File

@ -22,10 +22,10 @@
<h2>Data collected and transmitted</h2> <h2>Data collected and transmitted</h2>
<h3 id="web-logging">Web logging</h3> <h3 id="web-logging">Web logging</h3>
<p>This website and SMAPI's web API are hosted by Amazon Web Services. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web application or developers. For more information, see the <a href="https://aws.amazon.com/privacy/">Amazon Privacy Notice</a>.</p> <p>This website and SMAPI's web API are hosted on Microsoft Azure. Their servers may automatically collect diagnostics like your IP address, but this information is not visible to SMAPI's web apps or its developers. For more information, see the <a href="https://azure.microsoft.com/en-ca/support/legal/">Microsoft Azure legal resources</a>.</p>
<h3>Update checks</h3> <h3>Update checks</h3>
<p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web application, but see <em><a href="#web-logging">web logging</a></em>.</p> <p>SMAPI notifies you when there's a new version of SMAPI or your mods available. To do so, it sends basic metadata like your game/SMAPI/mod versions and platform type to its web API. No personal information is stored by the web app.</p>
<p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p> <p>You can disable update checks, and no information will be transmitted to the web API. To do so:</p>
<ol> <ol>
@ -34,8 +34,8 @@
<li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li> <li>change <code>"CheckForUpdates": true</code> to <code>"CheckForUpdates": false</code>.</li>
</ol> </ol>
<h3>Log parser</h3> <h3>Log parser and JSON validator</h3>
<p>The <a href="https://smapi.io/log">log parser page</a> lets you store a log file for analysis and sharing. The log data is stored indefinitely in an obfuscated form as unlisted pastes in <a href="https://pastebin.com/">Pastebin</a>. No personal information is stored by the log parser beyond what you choose to upload, but see <em><a href="#web-logging">web logging</a></em> and the <a href="https://pastebin.com/doc_privacy_statement">Pastebin Privacy Statement</a>.</p> <p>The <a href="https://smapi.io/log">log parser</a> and <a href="https://smapi.io/json">JSON validator</a> let you upload files to analyze and share with other users. The log data is stored for 30 days in an obfuscated form in a private Microsoft Azure Blob storage account. No personal information is stored by the log parser beyond what you choose to upload as part of those files.</p>
<h3>Multiplayer sync</h3> <h3>Multiplayer sync</h3>
<p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p> <p>As part of its multiplayer API, SMAPI transmits basic context to players you connect to (mainly your OS, SMAPI version, game version, and installed mods). This is used to enable multiplayer features like inter-mod messages, compatibility checks, etc. Although this information is normally hidden from players, it may be visible due to mods or configuration changes.</p>

View File

@ -1,13 +1,15 @@
@using Humanizer
@using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework
@using StardewModdingAPI.Web.ViewModels.JsonValidator @using StardewModdingAPI.Web.ViewModels.JsonValidator
@model JsonValidatorModel @model JsonValidatorModel
@{ @{
// get view data // get view data
string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID }); string curPageUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName, id = Model.PasteID }, absoluteUrl: true);
string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName }); string newUploadUrl = this.Url.PlainAction("Index", "JsonValidator", new { schemaName = Model.SchemaName });
string schemaDisplayName = null; string schemaDisplayName = null;
bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName != "None"; bool isValidSchema = Model.SchemaName != null && Model.SchemaFormats.TryGetValue(Model.SchemaName, out schemaDisplayName) && schemaDisplayName?.ToLower() != "none";
bool isEditView = Model.Content == null || Model.SchemaName?.ToLower() == "edit";
// build title // build title
ViewData["Title"] = "JSON validator"; ViewData["Title"] = "JSON validator";
@ -26,17 +28,17 @@
{ {
<meta name="robots" content="noindex" /> <meta name="robots" content="noindex" />
} }
<link rel="stylesheet" href="~/Content/css/json-validator.css" /> <link rel="stylesheet" href="~/Content/css/json-validator.css?r=20191204" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/themes/sunlight.default.min.css" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/npm/jquery@3.3.1/dist/jquery.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/sunlight.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/plugins/sunlight-plugin.linenumbers.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script> <script src="https://cdn.jsdelivr.net/gh/tmont/sunlight@1.22.0/src/lang/sunlight.javascript.min.js" crossorigin="anonymous"></script>
<script src="~/Content/js/json-validator.js"></script> <script src="~/Content/js/json-validator.js?r=20191204"></script>
<script> <script>
$(function() { $(function() {
smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", values: null)), @Json.Serialize(Model.PasteID)); smapi.jsonValidator(@Json.Serialize(this.Url.PlainAction("Index", "JsonValidator", new { schemaName = "$schemaName", id = "$id" })), @Json.Serialize(Model.PasteID));
}); });
</script> </script>
} }
@ -59,7 +61,7 @@ else if (Model.ParseError != null)
<small v-pre>Error details: @Model.ParseError</small> <small v-pre>Error details: @Model.ParseError</small>
</div> </div>
} }
else if (Model.PasteID != null) else if (!isEditView && Model.PasteID != null)
{ {
<div class="banner success"> <div class="banner success">
<strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br /> <strong>Share this link to let someone else see this page:</strong> <code>@curPageUrl</code><br />
@ -67,8 +69,20 @@ else if (Model.PasteID != null)
</div> </div>
} }
@* save warnings *@
@if (Model.UploadWarning != null || Model.Expiry != null)
{
<div class="save-metadata" v-pre>
@if (Model.Expiry != null)
{
<text>This JSON file will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text>
}
<!--@Model.UploadWarning-->
</div>
}
@* upload new file *@ @* upload new file *@
@if (Model.Content == null) @if (isEditView)
{ {
<h2>Upload a JSON file</h2> <h2>Upload a JSON file</h2>
<form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post"> <form action="@this.Url.PlainAction("PostAsync", "JsonValidator")" method="post">
@ -84,7 +98,7 @@ else if (Model.PasteID != null)
</li> </li>
<li> <li>
Drag the file onto this textbox (or paste the text in):<br /> Drag the file onto this textbox (or paste the text in):<br />
<textarea id="input" name="Content" placeholder="paste file here"></textarea> <textarea id="input" name="Content" placeholder="paste file here">@Model.Content</textarea>
</li> </li>
<li> <li>
Click this button:<br /> Click this button:<br />
@ -95,26 +109,23 @@ else if (Model.PasteID != null)
} }
@* validation results *@ @* validation results *@
@if (Model.Content != null) @if (!isEditView)
{ {
<div id="output"> <div id="output">
@if (Model.UploadError == null) @if (Model.UploadError == null)
{ {
<div> <h2>Validation</h2>
Change JSON format: <p>
<select id="format" name="format"> @(Model.Errors.Any() ? "Oops, found some issues with your JSON." : "No errors found!")
@foreach (var pair in Model.SchemaFormats) @if (!isValidSchema)
{ {
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option> <text>(You have no schema selected, so only the basic JSON syntax was checked.)</text>
} }
</select> else if (Model.FormatUrl != null)
</div>
<h2>Validation errors</h2>
@if (Model.FormatUrl != null)
{ {
<p>See <a href="@Model.FormatUrl">format documentation</a>.</p> <text>See <a href="@Model.FormatUrl">format documentation</a> for more info.</text>
} }
</p>
@if (Model.Errors.Any()) @if (Model.Errors.Any())
{ {
@ -135,13 +146,17 @@ else if (Model.PasteID != null)
} }
</table> </table>
} }
else
{
<p>No errors found.</p>
}
} }
<h2>Content</h2> <h2>Content</h2>
<div>
You can change JSON format (<select id="format" name="format">
@foreach (var pair in Model.SchemaFormats)
{
<option value="@pair.Key" selected="@(Model.SchemaName == pair.Key)">@pair.Value</option>
}
</select>) or <a href="@(this.Url.PlainAction("Index", "JsonValidator", new { id = this.Model.PasteID, schemaName = "edit" }))">edit this file</a>.
</div>
<pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre> <pre id="raw-content" class="sunlight-highlight-javascript">@Model.Content</pre>
@if (isValidSchema) @if (isValidSchema)

View File

@ -13,6 +13,8 @@
.Cast<LogLevel>() .Cast<LogLevel>()
.ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace); .ToDictionary(level => level.ToString().ToLower(), level => level != LogLevel.Trace);
JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None }; JsonSerializerSettings noFormatting = new JsonSerializerSettings { Formatting = Formatting.None };
string curPageUrl = this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }, absoluteUrl: true);
} }
@section Head { @section Head {
@ -50,7 +52,7 @@ else if (Model.ParseError != null)
{ {
<div class="banner error" v-pre> <div class="banner error" v-pre>
<strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br /> <strong>Oops, couldn't parse that log. (Make sure you upload the log file, not the console text.)</strong><br />
Share this URL when asking for help: <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID }))</code><br /> Share this URL when asking for help: <code>@curPageUrl</code><br />
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)<br /> (Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)<br />
<br /> <br />
<small v-pre>Error details: @Model.ParseError</small> <small v-pre>Error details: @Model.ParseError</small>
@ -59,7 +61,7 @@ else if (Model.ParseError != null)
else if (Model.ParsedLog?.IsValid == true) else if (Model.ParsedLog?.IsValid == true)
{ {
<div class="banner success" v-pre> <div class="banner success" v-pre>
<strong>Share this link to let someone else see the log:</strong> <code>https://@this.Context.Request.Host.ToUriComponent()@this.Url.PlainAction("Index", "LogParser", new { id = Model.PasteID })</code><br /> <strong>Share this link to let someone else see the log:</strong> <code>@curPageUrl</code><br />
(Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.) (Or <a href="@this.Url.PlainAction("Index", "LogParser", values: null)">upload a new log</a>.)
</div> </div>
} }
@ -67,12 +69,16 @@ else if (Model.ParsedLog?.IsValid == true)
@* save warnings *@ @* save warnings *@
@if (Model.UploadWarning != null || Model.Expiry != null) @if (Model.UploadWarning != null || Model.Expiry != null)
{ {
@if (Model.UploadWarning != null)
{
<text>⚠️ @Model.UploadWarning<br /></text>
}
<div class="save-metadata" v-pre> <div class="save-metadata" v-pre>
@if (Model.Expiry != null) @if (Model.Expiry != null)
{ {
<text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()). </text> <text>This log will expire in @((DateTime.UtcNow - Model.Expiry.Value).Humanize()).</text>
} }
<!--@Model.UploadWarning-->
</div> </div>
} }
@ -294,10 +300,7 @@ else if (Model.ParsedLog?.IsValid == true)
string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable string sectionFilter = message.Section != null && !message.IsStartOfSection ? $"&& sectionsAllow('{message.Section}')" : null; // filter the message by section if applicable
<tr class="mod @levelStr @sectionStartClass" <tr class="mod @levelStr @sectionStartClass"
@if (message.IsStartOfSection) @if (message.IsStartOfSection) { <text> v-on:click="toggleSection('@message.Section')" </text> }
{
<text>v-on:click="toggleSection('@message.Section')"</text>
}
v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter"> v-show="filtersAllow('@Model.GetSlug(message.Mod)', '@levelStr') @sectionFilter">
<td v-pre>@message.Time</td> <td v-pre>@message.Time</td>
<td v-pre>@message.Level.ToString().ToUpper()</td> <td v-pre>@message.Level.ToString().ToUpper()</td>
@ -307,8 +310,12 @@ else if (Model.ParsedLog?.IsValid == true)
@if (message.IsStartOfSection) @if (message.IsStartOfSection)
{ {
<span class="section-toggle-message"> <span class="section-toggle-message">
<template v-if="sectionsAllow('@message.Section')">This section is shown. Click here to hide it.</template> <template v-if="sectionsAllow('@message.Section')">
<template v-else>This section is hidden. Click here to show it.</template> This section is shown. Click here to hide it.
</template>
<template v-else>
This section is hidden. Click here to show it.
</template>
</span> </span>
} }
</td> </td>

View File

@ -8,28 +8,21 @@
*/ */
{ {
"Site": {
"BetaEnabled": false,
"BetaBlurb": null
},
"ApiClients": { "ApiClients": {
"AmazonAccessKey": null, "AzureBlobConnectionString": null,
"AmazonSecretKey": null,
"GitHubUsername": null, "GitHubUsername": null,
"GitHubPassword": null, "GitHubPassword": null,
"NexusApiKey": null, "NexusApiKey": null
"PastebinUserKey": null,
"PastebinDevKey": null
}, },
"MongoDB": { "MongoDB": {
"Host": "localhost", "ConnectionString": null,
"Username": null,
"Password": null,
"Database": "smapi-edge" "Database": "smapi-edge"
},
"BackgroundServices": {
"Enabled": true
} }
} }

View File

@ -16,17 +16,17 @@
}, },
"Site": { "Site": {
"BetaEnabled": null, "BetaEnabled": false,
"BetaBlurb": null "BetaBlurb": null,
"SupporterList": null
}, },
"ApiClients": { "ApiClients": {
"UserAgent": "SMAPI/{0} (+https://smapi.io)", "UserAgent": "SMAPI/{0} (+https://smapi.io)",
"AmazonAccessKey": null, "AzureBlobConnectionString": null,
"AmazonSecretKey": null, "AzureBlobTempContainer": "smapi-web-temp",
"AmazonRegion": "us-east-1", "AzureBlobTempExpiryDays": 30,
"AmazonLogBucket": "smapi-log-parser",
"ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishBaseUrl": "https://community.playstarbound.com",
"ChucklefishModPageUrlFormat": "resources/{0}", "ChucklefishModPageUrlFormat": "resources/{0}",
@ -46,16 +46,12 @@
"NexusModUrlFormat": "mods/{0}", "NexusModUrlFormat": "mods/{0}",
"NexusModScrapeUrlFormat": "mods/{0}?tab=files", "NexusModScrapeUrlFormat": "mods/{0}?tab=files",
"PastebinBaseUrl": "https://pastebin.com/", "PastebinBaseUrl": "https://pastebin.com/"
"PastebinUserKey": null,
"PastebinDevKey": null
}, },
"MongoDB": { "MongoDB": {
"Host": null, "ConnectionString": null,
"Username": null, "Database": "smapi"
"Password": null,
"Database": null
}, },
"ModCompatibilityList": { "ModCompatibilityList": {

View File

@ -41,6 +41,12 @@
background: #FCC; background: #FCC;
} }
.save-metadata {
margin-top: 1em;
font-size: 0.8em;
opacity: 0.3;
}
/********* /*********
** Validation results ** Validation results
*********/ *********/

View File

@ -70,10 +70,10 @@ smapi.LineNumberRange = function (maxLines) {
/** /**
* UI logic for the JSON validator page. * UI logic for the JSON validator page.
* @param {any} sectionUrl The base JSON validator page URL. * @param {string} urlFormat The URL format for a file, with $schemaName and $id placeholders.
* @param {any} pasteID The Pastebin paste ID for the content being viewed, if any. * @param {string} fileId The file ID for the content being viewed, if any.
*/ */
smapi.jsonValidator = function (sectionUrl, pasteID) { smapi.jsonValidator = function (urlFormat, fileId) {
/** /**
* The original content element. * The original content element.
*/ */
@ -138,7 +138,7 @@ smapi.jsonValidator = function (sectionUrl, pasteID) {
// change format // change format
$("#output #format").on("change", function() { $("#output #format").on("change", function() {
var schemaName = $(this).val(); var schemaName = $(this).val();
location.href = new URL(schemaName + "/" + pasteID, sectionUrl).toString(); location.href = urlFormat.replace("$schemaName", schemaName).replace("$id", fileId);
}); });
// upload form // upload form

View File

@ -11,9 +11,9 @@
"title": "Format version", "title": "Format version",
"description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.",
"type": "string", "type": "string",
"const": "1.9", "const": "1.11.0",
"@errorMessages": { "@errorMessages": {
"const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.9'." "const": "Incorrect value '@value'. This should be set to the latest format version, currently '1.11.0'."
} }
}, },
"ConfigSchema": { "ConfigSchema": {
@ -51,8 +51,7 @@
"if": { "if": {
"properties": { "properties": {
"AllowBlank": { "const": false } "AllowBlank": { "const": false }
}, }
"required": [ "AllowBlank" ]
}, },
"then": { "then": {
"required": [ "Default" ] "required": [ "Default" ]
@ -194,6 +193,8 @@
} }
}, },
"MoveEntries": { "MoveEntries": {
"title": "Move entries",
"description": "Change the entry order in a list asset like Data/MoviesReactions. (Using this with a non-list asset will cause an error, since those have no order.)",
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
@ -259,6 +260,14 @@
} }
} }
}, },
"MapProperties": {
"title": "Map properties",
"description": "The map properties (not tile properties) to add, replace, or delete. To add an property, just specify a key that doesn't exist; to delete an entry, set the value to null (like \"some key\": null). This field supports tokens in property keys and values.",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"When": { "When": {
"title": "When", "title": "When",
"description": "Only apply the patch if the given conditions match.", "description": "Only apply the patch if the given conditions match.",
@ -266,6 +275,9 @@
} }
}, },
"allOf": [ "allOf": [
{
"required": [ "Action" ]
},
{ {
"if": { "if": {
"properties": { "properties": {
@ -300,7 +312,7 @@
}, },
"then": { "then": {
"propertyNames": { "propertyNames": {
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "Fields", "Entries", "MoveEntries" ] "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "Fields", "Entries", "MoveEntries" ]
} }
} }
}, },
@ -313,7 +325,7 @@
"then": { "then": {
"properties": { "properties": {
"FromFile": { "FromFile": {
"description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you if it's a .tbin file:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder." "description": "The relative path to the map in your content pack folder from which to copy (like assets/town.tbin). This can be a .tbin or .xnb file. This field supports tokens and capitalization doesn't matter.\nContent Patcher will handle tilesheets referenced by the FromFile map for you:\n - If a tilesheet isn't referenced by the target map, Content Patcher will add it for you (with a z_ ID prefix to avoid conflicts with hardcoded game logic). If the source map has a custom version of a tilesheet that's already referenced, it'll be added as a separate tilesheet only used by your tiles.\n - If you include the tilesheet file in your mod folder, Content Patcher will use that one automatically; otherwise it will be loaded from the game's Content/Maps folder."
}, },
"FromArea": { "FromArea": {
"description": "The part of the source map to copy. Defaults to the whole source map." "description": "The part of the source map to copy. Defaults to the whole source map."
@ -323,9 +335,8 @@
} }
}, },
"propertyNames": { "propertyNames": {
"enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ] "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties" ]
}, }
"required": [ "FromFile", "ToArea" ]
} }
} }
], ],
@ -355,26 +366,26 @@
"properties": { "properties": {
"X": { "X": {
"title": "X-Coordinate", "title": "X-Coordinate",
"description": "Location in pixels of the top-left of the rectangle", "description": "The X position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.",
"type": "integer", "type": [ "integer", "string" ],
"minimum:": 0 "minimum:": 0
}, },
"Y": { "Y": {
"title": "Y-Coordinate", "title": "Y-Coordinate",
"description": "Location in pixels of the top-left of the rectangle", "description": "The Y position of the area's top-left corner, measured in pixels for a texture or tiles for a map. This can contain tokens.",
"type": "integer", "type": [ "integer", "string" ],
"minimum:": 0 "minimum:": 0
}, },
"Width": { "Width": {
"title": "Width", "title": "Width",
"description": "The width of the rectangle", "description": "The width of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
"type": "integer", "type": [ "integer", "string" ],
"minimum:": 0 "minimum:": 0
}, },
"Height": { "Height": {
"title": "Height", "title": "Height",
"description": "The height of the rectangle", "description": "The height of the area, measured in pixels for a texture or tiles for a map. This can contain tokens.",
"type": "integer", "type": [ "integer", "string" ],
"minimum:": 0 "minimum:": 0
} }
}, },

View File

@ -20,7 +20,7 @@ namespace StardewModdingAPI
** Public ** Public
****/ ****/
/// <summary>SMAPI's current semantic version.</summary> /// <summary>SMAPI's current semantic version.</summary>
public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.0.1"); public static ISemanticVersion ApiVersion { get; } = new Toolkit.SemanticVersion("3.1.0");
/// <summary>The minimum supported version of Stardew Valley.</summary> /// <summary>The minimum supported version of Stardew Valley.</summary>
public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0"); public static ISemanticVersion MinimumGameVersion { get; } = new GameVersion("1.4.0");

View File

@ -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;
}
}
}

View File

@ -23,6 +23,9 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after objects are added or removed in a location.</summary> /// <summary>Raised after objects are added or removed in a location.</summary>
event EventHandler<ObjectListChangedEventArgs> ObjectListChanged; event EventHandler<ObjectListChangedEventArgs> ObjectListChanged;
/// <summary>Raised after items are added or removed from a chest.</summary>
event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged;
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
} }

View File

@ -1,6 +1,5 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using StardewValley; using StardewValley;
namespace StardewModdingAPI.Events namespace StardewModdingAPI.Events
@ -14,13 +13,13 @@ namespace StardewModdingAPI.Events
/// <summary>The player whose inventory changed.</summary> /// <summary>The player whose inventory changed.</summary>
public Farmer Player { get; } public Farmer Player { get; }
/// <summary>The added items.</summary> /// <summary>The added item stacks.</summary>
public IEnumerable<Item> Added { get; } public IEnumerable<Item> Added { get; }
/// <summary>The removed items.</summary> /// <summary>The removed item stacks.</summary>
public IEnumerable<Item> Removed { get; } public IEnumerable<Item> Removed { get; }
/// <summary>The items whose stack sizes changed, with the relative change.</summary> /// <summary>The item stacks whose size changed.</summary>
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; } public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
/// <summary>Whether the affected player is the local one.</summary> /// <summary>Whether the affected player is the local one.</summary>
@ -32,28 +31,15 @@ namespace StardewModdingAPI.Events
*********/ *********/
/// <summary>Construct an instance.</summary> /// <summary>Construct an instance.</summary>
/// <param name="player">The player whose inventory changed.</param> /// <param name="player">The player whose inventory changed.</param>
/// <param name="changedItems">The inventory changes.</param> /// <param name="added">The added item stacks.</param>
internal InventoryChangedEventArgs(Farmer player, ItemStackChange[] changedItems) /// <param name="removed">The removed item stacks.</param>
/// <param name="quantityChanged">The item stacks whose size changed.</param>
internal InventoryChangedEventArgs(Farmer player, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
{ {
this.Player = player; this.Player = player;
this.Added = changedItems this.Added = added;
.Where(n => n.ChangeType == ChangeType.Added) this.Removed = removed;
.Select(p => p.Item) this.QuantityChanged = quantityChanged;
.ToArray();
this.Removed = changedItems
.Where(n => n.ChangeType == ChangeType.Removed)
.Select(p => p.Item)
.ToArray();
this.QuantityChanged = changedItems
.Where(n => n.ChangeType == ChangeType.StackChange)
.Select(change => new ItemStackSizeChange(
item: change.Item,
oldSize: change.Item.Stack - change.StackChange,
newSize: change.Item.Stack
))
.ToArray();
} }
} }
} }

View File

@ -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; }
}
}

View File

@ -42,8 +42,8 @@ namespace StardewModdingAPI.Framework.Content
Texture2D target = this.Data; Texture2D target = this.Data;
// get areas // get areas
sourceArea = sourceArea ?? new Rectangle(0, 0, source.Width, source.Height); sourceArea ??= new Rectangle(0, 0, source.Width, source.Height);
targetArea = targetArea ?? new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height)); targetArea ??= new Rectangle(0, 0, Math.Min(sourceArea.Value.Width, target.Width), Math.Min(sourceArea.Value.Height, target.Height));
// validate // validate
if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height) if (sourceArea.Value.X < 0 || sourceArea.Value.Y < 0 || sourceArea.Value.Right > source.Width || sourceArea.Value.Bottom > source.Height)

View File

@ -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;
}
}
}

View File

@ -119,13 +119,12 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the removed keys (if any).</returns> /// <returns>Returns the removed keys (if any).</returns>
public IEnumerable<string> Remove(Func<string, Type, bool> predicate, bool dispose = false) public IEnumerable<string> Remove(Func<string, object, bool> predicate, bool dispose)
{ {
List<string> removed = new List<string>(); List<string> removed = new List<string>();
foreach (string key in this.Cache.Keys.ToArray()) foreach (string key in this.Cache.Keys.ToArray())
{ {
Type type = this.Cache[key].GetType(); if (predicate(key, this.Cache[key]))
if (predicate(key, type))
{ {
this.Remove(key, dispose); this.Remove(key, dispose);
removed.Add(key); removed.Add(key);

View File

@ -3,11 +3,11 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection;
using Microsoft.Xna.Framework.Content; using Microsoft.Xna.Framework.Content;
using StardewModdingAPI.Framework.Content; using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.ContentManagers; using StardewModdingAPI.Framework.ContentManagers;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Metadata; using StardewModdingAPI.Metadata;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities;
@ -188,59 +188,6 @@ namespace StardewModdingAPI.Framework
return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false); return contentManager.Load<T>(relativePath, this.DefaultLanguage, useCache: false);
} }
/// <summary>Purge assets from the cache that match one of the interceptors.</summary>
/// <param name="editors">The asset editors for which to purge matching assets.</param>
/// <param name="loaders">The asset loaders for which to purge matching assets.</param>
/// <returns>Returns the invalidated asset names.</returns>
public IEnumerable<string> InvalidateCacheFor(IAssetEditor[] editors, IAssetLoader[] loaders)
{
if (!editors.Any() && !loaders.Any())
return new string[0];
// get CanEdit/Load methods
MethodInfo canEdit = typeof(IAssetEditor).GetMethod(nameof(IAssetEditor.CanEdit));
MethodInfo canLoad = typeof(IAssetLoader).GetMethod(nameof(IAssetLoader.CanLoad));
if (canEdit == null || canLoad == null)
throw new InvalidOperationException("SMAPI could not access the interceptor methods."); // should never happen
// invalidate matching keys
return this.InvalidateCache(asset =>
{
// check loaders
MethodInfo canLoadGeneric = canLoad.MakeGenericMethod(asset.DataType);
foreach (IAssetLoader loader in loaders)
{
try
{
if ((bool)canLoadGeneric.Invoke(loader, new object[] { asset }))
return true;
}
catch (Exception ex)
{
this.GetModFor(loader).LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
// check editors
MethodInfo canEditGeneric = canEdit.MakeGenericMethod(asset.DataType);
foreach (IAssetEditor editor in editors)
{
try
{
if ((bool)canEditGeneric.Invoke(editor, new object[] { asset }))
return true;
}
catch (Exception ex)
{
this.GetModFor(editor).LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
// asset not affected by a loader or editor
return false;
});
}
/// <summary>Purge matched assets from the cache.</summary> /// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
@ -261,24 +208,28 @@ namespace StardewModdingAPI.Framework
/// <returns>Returns the invalidated asset names.</returns> /// <returns>Returns the invalidated asset names.</returns>
public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) public IEnumerable<string> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{ {
// invalidate cache // invalidate cache & track removed assets
IDictionary<string, Type> removedAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase); IDictionary<string, ISet<object>> removedAssets = new Dictionary<string, ISet<object>>(StringComparer.InvariantCultureIgnoreCase);
foreach (IContentManager contentManager in this.ContentManagers) foreach (IContentManager contentManager in this.ContentManagers)
{ {
foreach (Tuple<string, Type> asset in contentManager.InvalidateCache(predicate, dispose)) foreach (var entry in contentManager.InvalidateCache(predicate, dispose))
removedAssetNames[asset.Item1] = asset.Item2; {
if (!removedAssets.TryGetValue(entry.Key, out ISet<object> assets))
removedAssets[entry.Key] = assets = new HashSet<object>(new ObjectReferenceComparer<object>());
assets.Add(entry.Value);
}
} }
// reload core game assets // reload core game assets
int reloaded = this.CoreAssets.Propagate(this.MainContentManager, removedAssetNames); // use an intercepted content manager if (removedAssets.Any())
{
// report result IDictionary<string, bool> propagated = this.CoreAssets.Propagate(this.MainContentManager, removedAssets.ToDictionary(p => p.Key, p => p.Value.First().GetType())); // use an intercepted content manager
if (removedAssetNames.Any()) this.Monitor.Log($"Invalidated {removedAssets.Count} asset names ({string.Join(", ", removedAssets.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}); propagated {propagated.Count(p => p.Value)} core assets.", LogLevel.Trace);
this.Monitor.Log($"Invalidated {removedAssetNames.Count} asset names: {string.Join(", ", removedAssetNames.Keys.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase))}. Reloaded {reloaded} core assets.", LogLevel.Trace); }
else else
this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace); this.Monitor.Log("Invalidated 0 cache entries.", LogLevel.Trace);
return removedAssetNames.Keys; return removedAssets.Keys;
} }
/// <summary>Dispose held resources.</summary> /// <summary>Dispose held resources.</summary>
@ -308,33 +259,5 @@ namespace StardewModdingAPI.Framework
this.ContentManagers.Remove(contentManager); this.ContentManagers.Remove(contentManager);
} }
/// <summary>Get the mod which registered an asset loader.</summary>
/// <param name="loader">The asset loader.</param>
/// <exception cref="KeyNotFoundException">The given loader couldn't be matched to a mod.</exception>
private IModMetadata GetModFor(IAssetLoader loader)
{
foreach (var pair in this.Loaders)
{
if (pair.Value.Contains(loader))
return pair.Key;
}
throw new KeyNotFoundException("This loader isn't associated with a known mod.");
}
/// <summary>Get the mod which registered an asset editor.</summary>
/// <param name="editor">The asset editor.</param>
/// <exception cref="KeyNotFoundException">The given editor couldn't be matched to a mod.</exception>
private IModMetadata GetModFor(IAssetEditor editor)
{
foreach (var pair in this.Editors)
{
if (pair.Value.Contains(editor))
return pair.Key;
}
throw new KeyNotFoundException("This editor isn't associated with a known mod.");
}
} }
} }

View File

@ -41,6 +41,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>A list of disposable assets.</summary> /// <summary>A list of disposable assets.</summary>
private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>(); private readonly List<WeakReference<IDisposable>> Disposables = new List<WeakReference<IDisposable>>();
/// <summary>The disposable assets tracked by the base content manager.</summary>
/// <remarks>This should be kept empty to avoid keeping disposable assets referenced forever, which prevents garbage collection when they're unused. Disposable assets are tracked by <see cref="Disposables"/> instead, which avoids a hard reference.</remarks>
private readonly List<IDisposable> BaseDisposableReferences;
/********* /*********
** Accessors ** Accessors
@ -84,6 +88,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get asset data // get asset data
this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase); this.LanguageCodes = this.GetKeyLocales().ToDictionary(p => p.Value, p => p.Key, StringComparer.InvariantCultureIgnoreCase);
this.BaseDisposableReferences = reflection.GetField<List<IDisposable>>(this, "disposableAssets").GetValue();
} }
/// <summary>Load an asset that has been processed by the content pipeline.</summary> /// <summary>Load an asset that has been processed by the content pipeline.</summary>
@ -184,25 +189,25 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary> /// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the invalidated asset names and types.</returns> /// <returns>Returns the invalidated asset names and instances.</returns>
public IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false) public IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false)
{ {
Dictionary<string, Type> removeAssetNames = new Dictionary<string, Type>(StringComparer.InvariantCultureIgnoreCase); IDictionary<string, object> removeAssets = new Dictionary<string, object>(StringComparer.InvariantCultureIgnoreCase);
this.Cache.Remove((key, type) => this.Cache.Remove((key, asset) =>
{ {
this.ParseCacheKey(key, out string assetName, out _); this.ParseCacheKey(key, out string assetName, out _);
if (removeAssetNames.ContainsKey(assetName)) if (removeAssets.ContainsKey(assetName))
return true; return true;
if (predicate(assetName, type)) if (predicate(assetName, asset.GetType()))
{ {
removeAssetNames[assetName] = type; removeAssets[assetName] = asset;
return true; return true;
} }
return false; return false;
}); }, dispose);
return removeAssetNames.Select(p => Tuple.Create(p.Key, p.Value)); return removeAssets;
} }
/// <summary>Dispose held resources.</summary> /// <summary>Dispose held resources.</summary>
@ -258,22 +263,29 @@ namespace StardewModdingAPI.Framework.ContentManagers
: base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable))); : base.ReadAsset<T>(assetName, disposable => this.Disposables.Add(new WeakReference<IDisposable>(disposable)));
} }
/// <summary>Inject an asset into the cache.</summary> /// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam> /// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param> /// <param name="value">The asset value.</param>
/// <param name="language">The language code for which to inject the asset.</param> /// <param name="language">The language code for which to inject the asset.</param>
protected virtual void Inject<T>(string assetName, T value, LanguageCode language) /// <param name="useCache">Whether to save the asset to the asset cache.</param>
protected virtual void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
{ {
// track asset key // track asset key
if (value is Texture2D texture) if (value is Texture2D texture)
texture.Name = assetName; texture.Name = assetName;
// cache asset // cache asset
if (useCache)
{
assetName = this.AssertAndNormalizeAssetName(assetName); assetName = this.AssertAndNormalizeAssetName(assetName);
this.Cache[assetName] = value; this.Cache[assetName] = value;
} }
// avoid hard disposable references; see remarks on the field
this.BaseDisposableReferences.Clear();
}
/// <summary>Parse a cache key into its component parts.</summary> /// <summary>Parse a cache key into its component parts.</summary>
/// <param name="cacheKey">The input cache key.</param> /// <param name="cacheKey">The input cache key.</param>
/// <param name="assetName">The original asset name.</param> /// <param name="assetName">The original asset name.</param>

View File

@ -83,8 +83,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath)) if (this.Coordinator.TryParseManagedAssetKey(assetName, out string contentManagerID, out string relativePath))
{ {
T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath); T managedAsset = this.Coordinator.LoadManagedAsset<T>(contentManagerID, relativePath);
if (useCache) this.TrackAsset(assetName, managedAsset, language, useCache);
this.Inject(assetName, managedAsset, language);
return managedAsset; return managedAsset;
} }
@ -111,7 +110,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
} }
// update cache & return data // update cache & return data
this.Inject(assetName, data, language); this.TrackAsset(assetName, data, language, useCache);
return data; return data;
} }
@ -131,7 +130,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
removeAssetNames.Contains(key) removeAssetNames.Contains(key)
|| (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName)) || (this.TryParseExplicitLanguageAssetKey(key, out string assetName, out _) && removeAssetNames.Contains(assetName))
) )
.Select(p => p.Item1) .Select(p => p.Key)
.OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase) .OrderBy(p => p, StringComparer.InvariantCultureIgnoreCase)
.ToArray(); .ToArray();
if (invalidated.Any()) if (invalidated.Any())
@ -169,18 +168,19 @@ namespace StardewModdingAPI.Framework.ContentManagers
return false; return false;
} }
/// <summary>Inject an asset into the cache.</summary> /// <summary>Add tracking data to an asset and add it to the cache.</summary>
/// <typeparam name="T">The type of asset to inject.</typeparam> /// <typeparam name="T">The type of asset to inject.</typeparam>
/// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param> /// <param name="assetName">The asset path relative to the loader root directory, not including the <c>.xnb</c> extension.</param>
/// <param name="value">The asset value.</param> /// <param name="value">The asset value.</param>
/// <param name="language">The language code for which to inject the asset.</param> /// <param name="language">The language code for which to inject the asset.</param>
protected override void Inject<T>(string assetName, T value, LanguageCode language) /// <param name="useCache">Whether to save the asset to the asset cache.</param>
protected override void TrackAsset<T>(string assetName, T value, LanguageCode language, bool useCache)
{ {
// handle explicit language in asset name // handle explicit language in asset name
{ {
if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage)) if (this.TryParseExplicitLanguageAssetKey(assetName, out string newAssetName, out LanguageCode newLanguage))
{ {
this.Inject(newAssetName, value, newLanguage); this.TrackAsset(newAssetName, value, newLanguage, useCache);
return; return;
} }
} }
@ -192,10 +192,12 @@ namespace StardewModdingAPI.Framework.ContentManagers
// only caches by the most specific key). // only caches by the most specific key).
// 2. Because a mod asset loader/editor may have changed the asset in a way that // 2. Because a mod asset loader/editor may have changed the asset in a way that
// doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`. // doesn't change the instance stored in the cache, e.g. using `asset.ReplaceWith`.
if (useCache)
{
string keyWithLocale = $"{assetName}.{this.GetLocale(language)}"; string keyWithLocale = $"{assetName}.{this.GetLocale(language)}";
base.Inject(assetName, value, language); base.TrackAsset(assetName, value, language, useCache: true);
if (this.Cache.ContainsKey(keyWithLocale)) if (this.Cache.ContainsKey(keyWithLocale))
base.Inject(keyWithLocale, value, language); base.TrackAsset(keyWithLocale, value, language, useCache: true);
// track whether the injected asset is translatable for is-loaded lookups // track whether the injected asset is translatable for is-loaded lookups
if (this.Cache.ContainsKey(keyWithLocale)) if (this.Cache.ContainsKey(keyWithLocale))
@ -211,6 +213,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
else else
this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error); this.Monitor.Log($"Asset '{assetName}' could not be found in the cache immediately after injection.", LogLevel.Error);
} }
}
/// <summary>Load an asset file directly from the underlying content manager.</summary> /// <summary>Load an asset file directly from the underlying content manager.</summary>
/// <typeparam name="T">The type of asset to load.</typeparam> /// <typeparam name="T">The type of asset to load.</typeparam>

View File

@ -66,7 +66,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
/// <summary>Purge matched assets from the cache.</summary> /// <summary>Purge matched assets from the cache.</summary>
/// <param name="predicate">Matches the asset keys to invalidate.</param> /// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param> /// <param name="dispose">Whether to dispose invalidated assets. This should only be <c>true</c> when they're being invalidated as part of a dispose, to avoid crashing the game.</param>
/// <returns>Returns the invalidated asset names and types.</returns> /// <returns>Returns the invalidated asset names and instances.</returns>
IEnumerable<Tuple<string, Type>> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false); IDictionary<string, object> InvalidateCache(Func<string, Type, bool> predicate, bool dispose = false);
} }
} }

View File

@ -105,6 +105,7 @@ namespace StardewModdingAPI.Framework.ContentManagers
// get local asset // get local asset
SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}"); SContentLoadException GetContentError(string reasonPhrase) => new SContentLoadException($"Failed loading asset '{assetName}' from {this.Name}: {reasonPhrase}");
T asset;
try try
{ {
// get file // get file
@ -118,22 +119,22 @@ namespace StardewModdingAPI.Framework.ContentManagers
// XNB file // XNB file
case ".xnb": case ".xnb":
{ {
T data = this.RawLoad<T>(assetName, useCache: false); asset = this.RawLoad<T>(assetName, useCache: false);
if (data is Map map) if (asset is Map map)
{ {
this.NormalizeTilesheetPaths(map); this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
} }
return data;
} }
break;
// unpacked data // unpacked data
case ".json": case ".json":
{ {
if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out T data)) if (!this.JsonHelper.ReadJsonFileIfExists(file.FullName, out asset))
throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above throw GetContentError("the JSON file is invalid."); // should never happen since we check for file existence above
return data;
} }
break;
// unpacked image // unpacked image
case ".png": case ".png":
@ -143,13 +144,13 @@ namespace StardewModdingAPI.Framework.ContentManagers
throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'."); throw GetContentError($"can't read file with extension '{file.Extension}' as type '{typeof(T)}'; must be type '{typeof(Texture2D)}'.");
// fetch & cache // fetch & cache
using (FileStream stream = File.OpenRead(file.FullName)) using FileStream stream = File.OpenRead(file.FullName);
{
Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream); Texture2D texture = Texture2D.FromStream(Game1.graphics.GraphicsDevice, stream);
texture = this.PremultiplyTransparency(texture); texture = this.PremultiplyTransparency(texture);
return (T)(object)texture; asset = (T)(object)texture;
}
} }
break;
// unpacked map // unpacked map
case ".tbin": case ".tbin":
@ -163,8 +164,9 @@ namespace StardewModdingAPI.Framework.ContentManagers
Map map = formatManager.LoadMap(file.FullName); Map map = formatManager.LoadMap(file.FullName);
this.NormalizeTilesheetPaths(map); this.NormalizeTilesheetPaths(map);
this.FixCustomTilesheetPaths(map, relativeMapPath: assetName); this.FixCustomTilesheetPaths(map, relativeMapPath: assetName);
return (T)(object)map; asset = (T)(object)map;
} }
break;
default: default:
throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'."); throw GetContentError($"unknown file extension '{file.Extension}'; must be one of '.json', '.png', '.tbin', or '.xnb'.");
@ -176,6 +178,10 @@ namespace StardewModdingAPI.Framework.ContentManagers
throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher."); throw GetContentError("couldn't find libgdiplus, which is needed to load mod images. Make sure Mono is installed and you're running the game through the normal launcher.");
throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex); throw new SContentLoadException($"The content manager failed loading content asset '{assetName}' from {this.Name}.", ex);
} }
// track & return asset
this.TrackAsset(assetName, asset, language, useCache);
return asset;
} }
/// <summary>Create a new content manager for temporary use.</summary> /// <summary>Create a new content manager for temporary use.</summary>

View File

@ -148,6 +148,9 @@ namespace StardewModdingAPI.Framework.Events
/// <summary>Raised after objects are added or removed in a location.</summary> /// <summary>Raised after objects are added or removed in a location.</summary>
public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged; public readonly ManagedEvent<ObjectListChangedEventArgs> ObjectListChanged;
/// <summary>Raised after items are added or removed from a chest.</summary>
public readonly ManagedEvent<ChestInventoryChangedEventArgs> ChestInventoryChanged;
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged; public readonly ManagedEvent<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged;
@ -221,6 +224,7 @@ namespace StardewModdingAPI.Framework.Events
this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged)); this.LocationListChanged = ManageEventOf<LocationListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.BuildingListChanged));
this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged)); this.NpcListChanged = ManageEventOf<NpcListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.NpcListChanged));
this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged)); this.ObjectListChanged = ManageEventOf<ObjectListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ObjectListChanged));
this.ChestInventoryChanged = ManageEventOf<ChestInventoryChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.ChestInventoryChanged));
this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged)); this.TerrainFeatureListChanged = ManageEventOf<TerrainFeatureListChangedEventArgs>(nameof(IModEvents.World), nameof(IWorldEvents.TerrainFeatureListChanged));
this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged)); this.LoadStageChanged = ManageEventOf<LoadStageChangedEventArgs>(nameof(IModEvents.Specialized), nameof(ISpecializedEvents.LoadStageChanged));

View File

@ -51,6 +51,13 @@ namespace StardewModdingAPI.Framework.Events
remove => this.EventManager.ObjectListChanged.Remove(value); remove => this.EventManager.ObjectListChanged.Remove(value);
} }
/// <summary>Raised after items are added or removed from a chest.</summary>
public event EventHandler<ChestInventoryChangedEventArgs> ChestInventoryChanged
{
add => this.EventManager.ChestInventoryChanged.Add(value);
remove => this.EventManager.ChestInventoryChanged.Remove(value);
}
/// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary> /// <summary>Raised after terrain features (like floors and trees) are added or removed in a location.</summary>
public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged public event EventHandler<TerrainFeatureListChangedEventArgs> TerrainFeatureListChanged
{ {

View File

@ -105,6 +105,10 @@ namespace StardewModdingAPI.Framework
/// <param name="validOnly">Only return valid update keys.</param> /// <param name="validOnly">Only return valid update keys.</param>
IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true); IEnumerable<UpdateKey> GetUpdateKeys(bool validOnly = true);
/// <summary>Get the mod IDs that must be installed to load this mod.</summary>
/// <param name="includeOptional">Whether to include optional dependencies.</param>
IEnumerable<string> GetRequiredModIds(bool includeOptional = false);
/// <summary>Whether the mod has at least one valid update key set.</summary> /// <summary>Whether the mod has at least one valid update key set.</summary>
bool HasValidUpdateKeys(); bool HasValidUpdateKeys();

View File

@ -129,6 +129,9 @@ namespace StardewModdingAPI.Framework.Input
[Obsolete("This method should only be called by the game itself.")] [Obsolete("This method should only be called by the game itself.")]
public override GamePadState GetGamePadState() public override GamePadState GetGamePadState()
{ {
if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff)
return base.GetGamePadState();
return this.ShouldSuppressNow() return this.ShouldSuppressNow()
? this.SuppressedController ? this.SuppressedController
: this.RealController; : this.RealController;

View File

@ -1,6 +1,8 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using Newtonsoft.Json; using Newtonsoft.Json;
using StardewModdingAPI.Enums;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Toolkit.Utilities;
using StardewValley; using StardewValley;
@ -77,33 +79,45 @@ namespace StardewModdingAPI.Framework.ModHelpers
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
public TModel ReadSaveData<TModel>(string key) where TModel : class public TModel ReadSaveData<TModel>(string key) where TModel : class
{ {
if (!Game1.hasLoadedGame) if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded."); throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} when a save file isn't loaded.");
if (!Game1.IsMasterGame) if (!Game1.IsMasterGame)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.ReadSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
return Game1.CustomData.TryGetValue(this.GetSaveFileKey(key), out string value)
? this.JsonHelper.Deserialize<TModel>(value) string internalKey = this.GetSaveFileKey(key);
: null; foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
{
if (dataField.TryGetValue(internalKey, out string value))
return this.JsonHelper.Deserialize<TModel>(value);
}
return null;
} }
/// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary> /// <summary>Save arbitrary data to the current save slot. This is only possible if a save has been loaded, and the data will be lost if the player exits without saving the current day.</summary>
/// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam> /// <typeparam name="TModel">The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types.</typeparam>
/// <param name="key">The unique key identifying the data.</param> /// <param name="key">The unique key identifying the data.</param>
/// <param name="data">The arbitrary data to save.</param> /// <param name="model">The arbitrary data to save.</param>
/// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception> /// <exception cref="InvalidOperationException">The player hasn't loaded a save file yet or isn't the main player.</exception>
public void WriteSaveData<TModel>(string key, TModel data) where TModel : class public void WriteSaveData<TModel>(string key, TModel model) where TModel : class
{ {
if (!Game1.hasLoadedGame) if (Context.LoadStage == LoadStage.None)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded."); throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} when a save file isn't loaded.");
if (!Game1.IsMasterGame) if (!Game1.IsMasterGame)
throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)"); throw new InvalidOperationException($"Can't use {nameof(IMod.Helper)}.{nameof(IModHelper.Data)}.{nameof(this.WriteSaveData)} because this isn't the main player. (Save files are stored on the main player's computer.)");
string internalKey = this.GetSaveFileKey(key); string internalKey = this.GetSaveFileKey(key);
string data = model != null
? this.JsonHelper.Serialize(model, Formatting.None)
: null;
foreach (IDictionary<string, string> dataField in this.GetDataFields(Context.LoadStage))
{
if (data != null) if (data != null)
Game1.CustomData[internalKey] = this.JsonHelper.Serialize(data, Formatting.None); dataField[internalKey] = data;
else else
Game1.CustomData.Remove(internalKey); dataField.Remove(internalKey);
}
} }
/**** /****
@ -146,6 +160,18 @@ namespace StardewModdingAPI.Framework.ModHelpers
return $"smapi/mod-data/{this.ModID}/{key}".ToLower(); return $"smapi/mod-data/{this.ModID}/{key}".ToLower();
} }
/// <summary>Get the data fields to read/write for save data.</summary>
/// <param name="stage">The current load stage.</param>
private IEnumerable<IDictionary<string, string>> GetDataFields(LoadStage stage)
{
if (stage == LoadStage.None)
yield break;
yield return Game1.CustomData;
if (SaveGame.loaded != null)
yield return SaveGame.loaded.CustomData;
}
/// <summary>Get the absolute path for a global data file.</summary> /// <summary>Get the absolute path for a global data file.</summary>
/// <param name="key">The unique key identifying the data.</param> /// <param name="key">The unique key identifying the data.</param>
private string GetGlobalDataPath(string key) private string GetGlobalDataPath(string key)

View File

@ -356,6 +356,11 @@ namespace StardewModdingAPI.Framework.ModLoading
mod.SetWarning(ModWarning.UsesDynamic); mod.SetWarning(ModWarning.UsesDynamic);
break; break;
case InstructionHandleResult.DetectedConsoleAccess:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected direct console access ({handler.NounPhrase}) in assembly {filename}.");
mod.SetWarning(ModWarning.AccessesConsole);
break;
case InstructionHandleResult.DetectedFilesystemAccess: case InstructionHandleResult.DetectedFilesystemAccess:
this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}."); this.Monitor.LogOnce(loggedMessages, $"{logPrefix}Detected filesystem access ({handler.NounPhrase}) in assembly {filename}.");
mod.SetWarning(ModWarning.AccessesFilesystem); mod.SetWarning(ModWarning.AccessesFilesystem);

View File

@ -26,6 +26,9 @@ namespace StardewModdingAPI.Framework.ModLoading
/// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary> /// <summary>The instruction is compatible, but references <see cref="ISpecializedEvents.UnvalidatedUpdateTicking"/> or <see cref="ISpecializedEvents.UnvalidatedUpdateTicked"/> which may impact stability.</summary>
DetectedUnvalidatedUpdateTick, DetectedUnvalidatedUpdateTick,
/// <summary>The instruction accesses the SMAPI console directly.</summary>
DetectedConsoleAccess,
/// <summary>The instruction accesses the filesystem directly.</summary> /// <summary>The instruction accesses the filesystem directly.</summary>
DetectedFilesystemAccess, DetectedFilesystemAccess,

View File

@ -188,6 +188,27 @@ namespace StardewModdingAPI.Framework.ModLoading
} }
} }
/// <summary>Get the mod IDs that must be installed to load this mod.</summary>
/// <param name="includeOptional">Whether to include optional dependencies.</param>
public IEnumerable<string> GetRequiredModIds(bool includeOptional = false)
{
HashSet<string> required = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
// yield dependencies
if (this.Manifest?.Dependencies != null)
{
foreach (var entry in this.Manifest?.Dependencies)
{
if ((entry.IsRequired || includeOptional) && required.Add(entry.UniqueID))
yield return entry.UniqueID;
}
}
// yield content pack parent
if (this.Manifest?.ContentPackFor?.UniqueID != null && required.Add(this.Manifest.ContentPackFor.UniqueID))
yield return this.Manifest.ContentPackFor.UniqueID;
}
/// <summary>Whether the mod has at least one valid update key set.</summary> /// <summary>Whether the mod has at least one valid update key set.</summary>
public bool HasValidUpdateKeys() public bool HasValidUpdateKeys()
{ {

View File

@ -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; }
}
}

View File

@ -25,8 +25,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI", [nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
[nameof(WebApiBaseUrl)] = "https://smapi.io/api/", [nameof(WebApiBaseUrl)] = "https://smapi.io/api/",
[nameof(VerboseLogging)] = false, [nameof(VerboseLogging)] = false,
[nameof(LogNetworkTraffic)] = false, [nameof(LogNetworkTraffic)] = false
[nameof(DumpMetadata)] = false
}; };
/// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary> /// <summary>The default values for <see cref="SuppressUpdateChecks"/>, to log changes if different.</summary>
@ -64,9 +63,6 @@ namespace StardewModdingAPI.Framework.Models
/// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary> /// <summary>Whether SMAPI should log network traffic. Best combined with <see cref="VerboseLogging"/>, which includes network metadata.</summary>
public bool LogNetworkTraffic { get; set; } public bool LogNetworkTraffic { get; set; }
/// <summary>Whether to generate a file in the mods folder with detailed metadata about the detected mods.</summary>
public bool DumpMetadata { get; set; }
/// <summary>The colors to use for text written to the SMAPI console.</summary> /// <summary>The colors to use for text written to the SMAPI console.</summary>
public ColorSchemeConfig ConsoleColors { get; set; } public ColorSchemeConfig ConsoleColors { get; set; }

View File

@ -65,6 +65,10 @@ namespace StardewModdingAPI.Framework.Reflection
{ {
result = this.MethodInfo.Invoke(this.Parent, arguments); result = this.MethodInfo.Invoke(this.Parent, arguments);
} }
catch (TargetParameterCountException)
{
throw new Exception($"Couldn't invoke the {this.DisplayName} method: it expects {this.MethodInfo.GetParameters().Length} parameters, but {arguments.Length} were provided.");
}
catch (Exception ex) catch (Exception ex)
{ {
throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex); throw new Exception($"Couldn't invoke the {this.DisplayName} method", ex);

View File

@ -97,16 +97,25 @@ namespace StardewModdingAPI.Framework
}; };
/// <summary>Regex patterns which match console messages to show a more friendly error for.</summary> /// <summary>Regex patterns which match console messages to show a more friendly error for.</summary>
private readonly Tuple<Regex, string, LogLevel>[] ReplaceConsolePatterns = private readonly ReplaceLogPattern[] ReplaceConsolePatterns =
{ {
Tuple.Create( // Steam not loaded
new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.", RegexOptions.Compiled | RegexOptions.CultureInvariant), new ReplaceLogPattern(
search: new Regex(@"^System\.InvalidOperationException: Steamworks is not initialized\.[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
replacement:
#if SMAPI_FOR_WINDOWS #if SMAPI_FOR_WINDOWS
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).", "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that (see 'Part 2: Configure Steam' in the install guide for more info: https://smapi.io/install).",
#else #else
"Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.", "Oops! Steam achievements won't work because Steam isn't loaded. You can launch the game through Steam to fix that.",
#endif #endif
LogLevel.Error logLevel: LogLevel.Error
),
// save file not found error
new ReplaceLogPattern(
search: new Regex(@"^System\.IO\.FileNotFoundException: [^\n]+\n[^:]+: '[^\n]+[/\\]Saves[/\\]([^'\r\n]+)[/\\]([^'\r\n]+)'[\s\S]+LoadGameMenu\.FindSaveGames[\s\S]+$", RegexOptions.Compiled | RegexOptions.CultureInvariant),
replacement: "The game can't find the '$2' file for your '$1' save. See https://stardewvalleywiki.com/Saves#Troubleshooting for help.",
logLevel: LogLevel.Error
) )
}; };
@ -426,20 +435,6 @@ namespace StardewModdingAPI.Framework
mods = resolver.ProcessDependencies(mods, modDatabase).ToArray(); mods = resolver.ProcessDependencies(mods, modDatabase).ToArray();
this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase); this.LoadMods(mods, this.Toolkit.JsonHelper, this.ContentCore, modDatabase);
// write metadata file
if (this.Settings.DumpMetadata)
{
ModFolderExport export = new ModFolderExport
{
Exported = DateTime.UtcNow.ToString("O"),
ApiVersion = Constants.ApiVersion.ToString(),
GameVersion = Constants.GameVersion.ToString(),
ModFolderPath = this.ModsPath,
Mods = mods
};
this.Toolkit.JsonHelper.WriteJsonFile(Path.Combine(Constants.LogDir, $"{Constants.LogNamePrefix}metadata-dump.json"), export);
}
// check for updates // check for updates
this.CheckForUpdatesAsync(mods); this.CheckForUpdatesAsync(mods);
} }
@ -774,7 +769,7 @@ namespace StardewModdingAPI.Framework
this.Monitor.Log( this.Monitor.Log(
$" {metadata.DisplayName} {manifest.Version}" $" {metadata.DisplayName} {manifest.Version}"
+ (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "") + (!string.IsNullOrWhiteSpace(manifest.Author) ? $" by {manifest.Author}" : "")
+ (metadata.IsContentPack ? $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}" : "") + $" | for {GetModDisplayName(metadata.Manifest.ContentPackFor.UniqueID)}"
+ (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""), + (!string.IsNullOrWhiteSpace(manifest.Description) ? $" | {manifest.Description}" : ""),
LogLevel.Info LogLevel.Info
); );
@ -842,32 +837,9 @@ namespace StardewModdingAPI.Framework
{ {
if (metadata.Mod.Helper.Content is ContentHelper helper) if (metadata.Mod.Helper.Content is ContentHelper helper)
{ {
helper.ObservableAssetEditors.CollectionChanged += (sender, e) => helper.ObservableAssetEditors.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
{ helper.ObservableAssetLoaders.CollectionChanged += (sender, e) => this.GameInstance.OnAssetInterceptorsChanged(metadata, e.NewItems, e.OldItems);
if (e.NewItems?.Count > 0)
{
this.Monitor.Log("Invalidating cache entries for new asset editors...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(e.NewItems.Cast<IAssetEditor>().ToArray(), new IAssetLoader[0]);
} }
};
helper.ObservableAssetLoaders.CollectionChanged += (sender, e) =>
{
if (e.NewItems?.Count > 0)
{
this.Monitor.Log("Invalidating cache entries for new asset loaders...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(new IAssetEditor[0], e.NewItems.Cast<IAssetLoader>().ToArray());
}
};
}
}
// reset cache now if any editors or loaders were added during entry
IAssetEditor[] editors = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetEditors).ToArray();
IAssetLoader[] loaders = loadedMods.SelectMany(p => p.Mod.Helper.Content.AssetLoaders).ToArray();
if (editors.Any() || loaders.Any())
{
this.Monitor.Log("Invalidating cached assets for new editors & loaders...", LogLevel.Trace);
this.ContentCore.InvalidateCacheFor(editors, loaders);
} }
// unlock mod integrations // unlock mod integrations
@ -1060,26 +1032,48 @@ namespace StardewModdingAPI.Framework
// log skipped mods // log skipped mods
if (skippedMods.Any()) if (skippedMods.Any())
{ {
// get logging logic
HashSet<string> logged = new HashSet<string>();
void LogSkippedMod(IModMetadata mod, string errorReason, string errorDetails)
{
string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
if (logged.Add($"{message}|{errorDetails}"))
{
this.Monitor.Log(message, LogLevel.Error);
if (errorDetails != null)
this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
}
}
// find skipped dependencies
KeyValuePair<IModMetadata, Tuple<string, string>>[] skippedDependencies;
{
HashSet<string> skippedDependencyIds = new HashSet<string>(StringComparer.InvariantCultureIgnoreCase);
HashSet<string> skippedModIds = new HashSet<string>(from mod in skippedMods where mod.Key.HasID() select mod.Key.Manifest.UniqueID, StringComparer.InvariantCultureIgnoreCase);
foreach (IModMetadata mod in skippedMods.Keys)
{
foreach (string requiredId in skippedModIds.Intersect(mod.GetRequiredModIds()))
skippedDependencyIds.Add(requiredId);
}
skippedDependencies = skippedMods.Where(p => p.Key.HasID() && skippedDependencyIds.Contains(p.Key.Manifest.UniqueID)).ToArray();
}
// log skipped mods
this.Monitor.Log(" Skipped mods", LogLevel.Error); this.Monitor.Log(" Skipped mods", LogLevel.Error);
this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error); this.Monitor.Log(" " + "".PadRight(50, '-'), LogLevel.Error);
this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error); this.Monitor.Log(" These mods could not be added to your game.", LogLevel.Error);
this.Monitor.Newline(); this.Monitor.Newline();
HashSet<string> logged = new HashSet<string>(); if (skippedDependencies.Any())
foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
{ {
IModMetadata mod = pair.Key; foreach (var pair in skippedDependencies.OrderBy(p => p.Key.DisplayName))
string errorReason = pair.Value.Item1; LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
string errorDetails = pair.Value.Item2; this.Monitor.Newline();
string message = $" - {mod.DisplayName}{(mod.Manifest?.Version != null ? " " + mod.Manifest.Version.ToString() : "")} because {errorReason}";
if (!logged.Add($"{message}|{errorDetails}"))
continue; // skip duplicate messages (e.g. if multiple copies of the mod are installed)
this.Monitor.Log(message, LogLevel.Error);
if (errorDetails != null)
this.Monitor.Log($" ({errorDetails})", LogLevel.Trace);
} }
foreach (var pair in skippedMods.OrderBy(p => p.Key.DisplayName))
LogSkippedMod(pair.Key, pair.Value.Item1, pair.Value.Item2);
this.Monitor.Newline(); this.Monitor.Newline();
} }
@ -1116,6 +1110,10 @@ namespace StardewModdingAPI.Framework
); );
if (this.Settings.ParanoidWarnings) if (this.Settings.ParanoidWarnings)
{ {
LogWarningGroup(ModWarning.AccessesConsole, LogLevel.Warn, "Accesses the console directly",
"These mods directly access the SMAPI console, and you enabled paranoid warnings. (Note that this may be",
"legitimate and innocent usage; this warning is meaningless without further investigation.)"
);
LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly", LogWarningGroup(ModWarning.AccessesFilesystem, LogLevel.Warn, "Accesses filesystem directly",
"These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be", "These mods directly access the filesystem, and you enabled paranoid warnings. (Note that this may be",
"legitimate and innocent usage; this warning is meaningless without further investigation.)" "legitimate and innocent usage; this warning is meaningless without further investigation.)"
@ -1317,11 +1315,12 @@ namespace StardewModdingAPI.Framework
return; return;
// show friendly error if applicable // show friendly error if applicable
foreach (var entry in this.ReplaceConsolePatterns) foreach (ReplaceLogPattern entry in this.ReplaceConsolePatterns)
{ {
if (entry.Item1.IsMatch(message)) string newMessage = entry.Search.Replace(message, entry.Replacement);
if (message != newMessage)
{ {
this.Monitor.Log(entry.Item2, entry.Item3); gameMonitor.Log(newMessage, entry.LogLevel);
gameMonitor.Log(message, LogLevel.Trace); gameMonitor.Log(message, LogLevel.Trace);
return; return;
} }
@ -1411,5 +1410,36 @@ namespace StardewModdingAPI.Framework
} }
} }
} }
/// <summary>A console log pattern to replace with a different message.</summary>
private class ReplaceLogPattern
{
/*********
** Accessors
*********/
/// <summary>The regex pattern matching the portion of the message to replace.</summary>
public Regex Search { get; }
/// <summary>The replacement string.</summary>
public string Replacement { get; }
/// <summary>The log level for the new message.</summary>
public LogLevel LogLevel { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="search">The regex pattern matching the portion of the message to replace.</param>
/// <param name="replacement">The replacement string.</param>
/// <param name="logLevel">The log level for the new message.</param>
public ReplaceLogPattern(Regex search, string replacement, LogLevel logLevel)
{
this.Search = search;
this.Replacement = replacement;
this.LogLevel = logLevel;
}
}
} }
} }

View File

@ -1,4 +1,5 @@
using System; using System;
using System.Collections;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
@ -12,10 +13,12 @@ using Microsoft.Xna.Framework.Graphics;
using Netcode; using Netcode;
using StardewModdingAPI.Enums; using StardewModdingAPI.Enums;
using StardewModdingAPI.Events; using StardewModdingAPI.Events;
using StardewModdingAPI.Framework.Content;
using StardewModdingAPI.Framework.Events; using StardewModdingAPI.Framework.Events;
using StardewModdingAPI.Framework.Input; using StardewModdingAPI.Framework.Input;
using StardewModdingAPI.Framework.Networking; using StardewModdingAPI.Framework.Networking;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.Snapshots; using StardewModdingAPI.Framework.StateTracking.Snapshots;
using StardewModdingAPI.Framework.Utilities; using StardewModdingAPI.Framework.Utilities;
using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Serialization;
@ -99,7 +102,7 @@ namespace StardewModdingAPI.Framework
private WatcherCore Watchers; private WatcherCore Watchers;
/// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary> /// <summary>A snapshot of the current <see cref="Watchers"/> state.</summary>
private WatcherSnapshot WatcherSnapshot = new WatcherSnapshot(); private readonly WatcherSnapshot WatcherSnapshot = new WatcherSnapshot();
/// <summary>Whether post-game-startup initialization has been performed.</summary> /// <summary>Whether post-game-startup initialization has been performed.</summary>
private bool IsInitialized; private bool IsInitialized;
@ -133,6 +136,9 @@ namespace StardewModdingAPI.Framework
/// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks> /// <remarks>This property must be threadsafe, since it's accessed from a separate console input thread.</remarks>
public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>(); public ConcurrentQueue<string> CommandQueue { get; } = new ConcurrentQueue<string>();
/// <summary>Asset interceptors added or removed since the last tick.</summary>
private readonly List<AssetInterceptorChange> ReloadAssetInterceptorsQueue = new List<AssetInterceptorChange>();
/********* /*********
** Protected methods ** Protected methods
@ -249,6 +255,24 @@ namespace StardewModdingAPI.Framework
this.Events.ReturnedToTitle.RaiseEmpty(); this.Events.ReturnedToTitle.RaiseEmpty();
} }
/// <summary>A callback invoked when a mod adds or removes an asset interceptor.</summary>
/// <param name="mod">The mod which added or removed interceptors.</param>
/// <param name="added">The added interceptors.</param>
/// <param name="removed">The removed interceptors.</param>
internal void OnAssetInterceptorsChanged(IModMetadata mod, IEnumerable added, IEnumerable removed)
{
if (added != null)
{
foreach (object instance in added)
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: true));
}
if (removed != null)
{
foreach (object instance in removed)
this.ReloadAssetInterceptorsQueue.Add(new AssetInterceptorChange(mod, instance, wasAdded: false));
}
}
/// <summary>Constructor a content manager to read XNB files.</summary> /// <summary>Constructor a content manager to read XNB files.</summary>
/// <param name="serviceProvider">The service provider to use to locate services.</param> /// <param name="serviceProvider">The service provider to use to locate services.</param>
/// <param name="rootDirectory">The root directory to search for content.</param> /// <param name="rootDirectory">The root directory to search for content.</param>
@ -404,6 +428,38 @@ namespace StardewModdingAPI.Framework
return; return;
} }
/*********
** Reload assets when interceptors are added/removed
*********/
if (this.ReloadAssetInterceptorsQueue.Any())
{
// get unique interceptors
AssetInterceptorChange[] interceptors = this.ReloadAssetInterceptorsQueue
.GroupBy(p => p.Instance, new ObjectReferenceComparer<object>())
.Select(p => p.First())
.ToArray();
this.ReloadAssetInterceptorsQueue.Clear();
// log summary
this.Monitor.Log("Invalidating cached assets for new editors & loaders...");
this.Monitor.Log(
" changed: "
+ string.Join(", ",
interceptors
.GroupBy(p => p.Mod)
.OrderBy(p => p.Key.DisplayName)
.Select(modGroup =>
$"{modGroup.Key.DisplayName} ("
+ string.Join(", ", modGroup.GroupBy(p => p.WasAdded).ToDictionary(p => p.Key, p => p.Count()).Select(p => $"{(p.Key ? "added" : "removed")} {p.Value}"))
+ ")"
)
)
);
// reload affected assets
this.ContentCore.InvalidateCache(asset => interceptors.Any(p => p.CanIntercept(asset)));
}
/********* /*********
** Execute commands ** Execute commands
*********/ *********/
@ -654,6 +710,16 @@ namespace StardewModdingAPI.Framework
if (locState.Objects.IsChanged) if (locState.Objects.IsChanged)
events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed)); events.ObjectListChanged.Raise(new ObjectListChangedEventArgs(location, locState.Objects.Added, locState.Objects.Removed));
// chest items changed
if (events.ChestInventoryChanged.HasListeners())
{
foreach (var pair in locState.ChestItems)
{
SnapshotItemListDiff diff = pair.Value;
events.ChestInventoryChanged.Raise(new ChestInventoryChangedEventArgs(pair.Key, location, added: diff.Added, removed: diff.Removed, quantityChanged: diff.QuantityChanged));
}
}
// terrain features changed // terrain features changed
if (locState.TerrainFeatures.IsChanged) if (locState.TerrainFeatures.IsChanged)
events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed)); events.TerrainFeatureListChanged.Raise(new TerrainFeatureListChangedEventArgs(location, locState.TerrainFeatures.Added, locState.TerrainFeatures.Removed));
@ -692,12 +758,13 @@ namespace StardewModdingAPI.Framework
} }
// raise player inventory changed // raise player inventory changed
ItemStackChange[] changedItems = playerState.InventoryChanges.ToArray(); if (playerState.Inventory.IsChanged)
if (changedItems.Any())
{ {
var inventory = playerState.Inventory;
if (this.Monitor.IsVerbose) if (this.Monitor.IsVerbose)
this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace); this.Monitor.Log("Events: player inventory changed.", LogLevel.Trace);
events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, changedItems)); events.InventoryChanged.Raise(new InventoryChangedEventArgs(player, added: inventory.Added, removed: inventory.Removed, quantityChanged: inventory.QuantityChanged));
} }
} }
} }

View File

@ -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;
}
}
}

View File

@ -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();
}
}
}

View File

@ -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);
}
}
}

View File

@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>The pairs removed since the last reset.</summary> /// <summary>The pairs removed since the last reset.</summary>
private readonly List<TValue> RemovedImpl = new List<TValue>(); private readonly List<TValue> RemovedImpl = new List<TValue>();
/// <summary>The previous values as of the last update.</summary>
private readonly List<TValue> PreviousValues = new List<TValue>();
/********* /*********
** Accessors ** Accessors
@ -78,10 +81,27 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <param name="e">The event arguments.</param> /// <param name="e">The event arguments.</param>
private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) private void OnCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
{ {
if (e.NewItems != null) if (e.Action == NotifyCollectionChangedAction.Reset)
this.AddedImpl.AddRange(e.NewItems.Cast<TValue>()); {
if (e.OldItems != null) this.RemovedImpl.AddRange(this.PreviousValues);
this.RemovedImpl.AddRange(e.OldItems.Cast<TValue>()); this.PreviousValues.Clear();
}
else
{
TValue[] added = e.NewItems?.Cast<TValue>().ToArray();
TValue[] removed = e.OldItems?.Cast<TValue>().ToArray();
if (removed != null)
{
this.RemovedImpl.AddRange(removed);
this.PreviousValues.RemoveRange(e.OldStartingIndex, removed.Length);
}
if (added != null)
{
this.AddedImpl.AddRange(added);
this.PreviousValues.InsertRange(e.NewStartingIndex, added);
}
}
} }
} }
} }

View File

@ -82,6 +82,14 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
return new NetCollectionWatcher<T>(collection); return new NetCollectionWatcher<T>(collection);
} }
/// <summary>Get a watcher for a net list.</summary>
/// <typeparam name="T">The value type.</typeparam>
/// <param name="collection">The net list.</param>
public static ICollectionWatcher<T> ForNetList<T>(NetList<T, NetRef<T>> collection) where T : class, INetObject<INetSerializable>
{
return new NetListWatcher<T>(collection);
}
/// <summary>Get a watcher for a net dictionary.</summary> /// <summary>Get a watcher for a net dictionary.</summary>
/// <typeparam name="TKey">The dictionary key type.</typeparam> /// <typeparam name="TKey">The dictionary key type.</typeparam>
/// <typeparam name="TValue">The dictionary value type.</typeparam> /// <typeparam name="TValue">The dictionary value type.</typeparam>

View File

@ -5,8 +5,9 @@ using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley; using StardewValley;
using StardewValley.Buildings; using StardewValley.Buildings;
using StardewValley.Locations; using StardewValley.Locations;
using StardewValley.Objects;
using StardewValley.TerrainFeatures; using StardewValley.TerrainFeatures;
using Object = StardewValley.Object; using SObject = StardewValley.Object;
namespace StardewModdingAPI.Framework.StateTracking namespace StardewModdingAPI.Framework.StateTracking
{ {
@ -42,11 +43,14 @@ namespace StardewModdingAPI.Framework.StateTracking
public ICollectionWatcher<NPC> NpcsWatcher { get; } public ICollectionWatcher<NPC> NpcsWatcher { get; }
/// <summary>Tracks added or removed objects.</summary> /// <summary>Tracks added or removed objects.</summary>
public IDictionaryWatcher<Vector2, Object> ObjectsWatcher { get; } public IDictionaryWatcher<Vector2, SObject> ObjectsWatcher { get; }
/// <summary>Tracks added or removed terrain features.</summary> /// <summary>Tracks added or removed terrain features.</summary>
public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; } public IDictionaryWatcher<Vector2, TerrainFeature> TerrainFeaturesWatcher { get; }
/// <summary>Tracks items added or removed to chests.</summary>
public IDictionary<Vector2, ChestTracker> ChestWatchers { get; } = new Dictionary<Vector2, ChestTracker>();
/********* /*********
** Public methods ** Public methods
@ -74,13 +78,8 @@ namespace StardewModdingAPI.Framework.StateTracking
this.ObjectsWatcher, this.ObjectsWatcher,
this.TerrainFeaturesWatcher this.TerrainFeaturesWatcher
}); });
}
/// <summary>Stop watching the player fields and release all references.</summary> this.UpdateChestWatcherList(added: location.Objects.Pairs, removed: new KeyValuePair<Vector2, SObject>[0]);
public void Dispose()
{
foreach (IWatcher watcher in this.Watchers)
watcher.Dispose();
} }
/// <summary>Update the current value if needed.</summary> /// <summary>Update the current value if needed.</summary>
@ -88,6 +87,11 @@ namespace StardewModdingAPI.Framework.StateTracking
{ {
foreach (IWatcher watcher in this.Watchers) foreach (IWatcher watcher in this.Watchers)
watcher.Update(); watcher.Update();
this.UpdateChestWatcherList(added: this.ObjectsWatcher.Added, removed: this.ObjectsWatcher.Removed);
foreach (var watcher in this.ChestWatchers)
watcher.Value.Update();
} }
/// <summary>Set the current value as the baseline.</summary> /// <summary>Set the current value as the baseline.</summary>
@ -95,6 +99,46 @@ namespace StardewModdingAPI.Framework.StateTracking
{ {
foreach (IWatcher watcher in this.Watchers) foreach (IWatcher watcher in this.Watchers)
watcher.Reset(); watcher.Reset();
foreach (var watcher in this.ChestWatchers)
watcher.Value.Reset();
}
/// <summary>Stop watching the player fields and release all references.</summary>
public void Dispose()
{
foreach (IWatcher watcher in this.Watchers)
watcher.Dispose();
foreach (var watcher in this.ChestWatchers.Values)
watcher.Dispose();
}
/*********
** Private methods
*********/
/// <summary>Update the watcher list for added or removed chests.</summary>
/// <param name="added">The objects added to the location.</param>
/// <param name="removed">The objects removed from the location.</param>
private void UpdateChestWatcherList(IEnumerable<KeyValuePair<Vector2, SObject>> added, IEnumerable<KeyValuePair<Vector2, SObject>> removed)
{
// remove unused watchers
foreach (KeyValuePair<Vector2, SObject> pair in removed)
{
if (pair.Value is Chest && this.ChestWatchers.TryGetValue(pair.Key, out ChestTracker watcher))
{
watcher.Dispose();
this.ChestWatchers.Remove(pair.Key);
}
}
// add new watchers
foreach (KeyValuePair<Vector2, SObject> pair in added)
{
if (pair.Value is Chest chest && !this.ChestWatchers.ContainsKey(pair.Key))
this.ChestWatchers.Add(pair.Key, new ChestTracker(chest));
}
} }
} }
} }

View File

@ -2,10 +2,9 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using StardewModdingAPI.Enums; using StardewModdingAPI.Enums;
using StardewModdingAPI.Events; using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.FieldWatchers; using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley; using StardewValley;
using ChangeType = StardewModdingAPI.Events.ChangeType;
namespace StardewModdingAPI.Framework.StateTracking namespace StardewModdingAPI.Framework.StateTracking
{ {
@ -99,25 +98,32 @@ namespace StardewModdingAPI.Framework.StateTracking
return this.Player.currentLocation ?? this.LastValidLocation; return this.Player.currentLocation ?? this.LastValidLocation;
} }
/// <summary>Get the player inventory changes between two states.</summary> /// <summary>Get the inventory changes since the last update, if anything changed.</summary>
public IEnumerable<ItemStackChange> GetInventoryChanges() /// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
{ {
IDictionary<Item, int> previous = this.PreviousInventory;
IDictionary<Item, int> current = this.GetInventory(); IDictionary<Item, int> current = this.GetInventory();
foreach (Item item in previous.Keys.Union(current.Keys))
ISet<Item> added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
ISet<Item> removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
foreach (Item item in this.PreviousInventory.Keys.Union(current.Keys))
{ {
if (!previous.TryGetValue(item, out int prevStack)) if (!this.PreviousInventory.ContainsKey(item))
yield return new ItemStackChange { Item = item, StackChange = item.Stack, ChangeType = ChangeType.Added }; added.Add(item);
else if (!current.TryGetValue(item, out int newStack)) else if (!current.ContainsKey(item))
yield return new ItemStackChange { Item = item, StackChange = -item.Stack, ChangeType = ChangeType.Removed }; removed.Add(item);
else if (prevStack != newStack)
yield return new ItemStackChange { Item = item, StackChange = newStack - prevStack, ChangeType = ChangeType.StackChange };
}
} }
/// <summary>Stop watching the player fields and release all references.</summary> return SnapshotItemListDiff.TryGetChanges(added: added, removed: removed, stackSizes: this.PreviousInventory, out changes);
}
/// <summary>Release watchers and resources.</summary>
public void Dispose() public void Dispose()
{ {
this.PreviousInventory.Clear();
this.CurrentInventory?.Clear();
foreach (IWatcher watcher in this.Watchers) foreach (IWatcher watcher in this.Watchers)
watcher.Dispose(); watcher.Dispose();
} }

View File

@ -2,6 +2,7 @@ using System.Collections.Generic;
using Microsoft.Xna.Framework; using Microsoft.Xna.Framework;
using StardewValley; using StardewValley;
using StardewValley.Buildings; using StardewValley.Buildings;
using StardewValley.Objects;
using StardewValley.TerrainFeatures; using StardewValley.TerrainFeatures;
namespace StardewModdingAPI.Framework.StateTracking.Snapshots namespace StardewModdingAPI.Framework.StateTracking.Snapshots
@ -33,6 +34,9 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <summary>Tracks added or removed terrain features.</summary> /// <summary>Tracks added or removed terrain features.</summary>
public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>(); public SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>> TerrainFeatures { get; } = new SnapshotListDiff<KeyValuePair<Vector2, TerrainFeature>>();
/// <summary>Tracks changed chest inventories.</summary>
public IDictionary<Chest, SnapshotItemListDiff> ChestItems { get; } = new Dictionary<Chest, SnapshotItemListDiff>();
/********* /*********
** Public methods ** Public methods
@ -48,12 +52,21 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <param name="watcher">The watcher to snapshot.</param> /// <param name="watcher">The watcher to snapshot.</param>
public void Update(LocationTracker watcher) public void Update(LocationTracker watcher)
{ {
// main lists
this.Buildings.Update(watcher.BuildingsWatcher); this.Buildings.Update(watcher.BuildingsWatcher);
this.Debris.Update(watcher.DebrisWatcher); this.Debris.Update(watcher.DebrisWatcher);
this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher); this.LargeTerrainFeatures.Update(watcher.LargeTerrainFeaturesWatcher);
this.Npcs.Update(watcher.NpcsWatcher); this.Npcs.Update(watcher.NpcsWatcher);
this.Objects.Update(watcher.ObjectsWatcher); this.Objects.Update(watcher.ObjectsWatcher);
this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher); this.TerrainFeatures.Update(watcher.TerrainFeaturesWatcher);
// chest inventories
this.ChestItems.Clear();
foreach (ChestTracker tracker in watcher.ChestWatchers.Values)
{
if (tracker.TryGetInventoryChanges(out SnapshotItemListDiff changes))
this.ChestItems[tracker.Chest] = changes;
}
} }
} }
} }

View File

@ -10,6 +10,13 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
/// <summary>A frozen snapshot of a tracked player.</summary> /// <summary>A frozen snapshot of a tracked player.</summary>
internal class PlayerSnapshot internal class PlayerSnapshot
{ {
/*********
** Fields
*********/
/// <summary>An empty item list diff.</summary>
private readonly SnapshotItemListDiff EmptyItemListDiff = new SnapshotItemListDiff(new Item[0], new Item[0], new ItemStackSizeChange[0]);
/********* /*********
** Accessors ** Accessors
*********/ *********/
@ -27,7 +34,7 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
.ToDictionary(skill => skill, skill => new SnapshotDiff<int>()); .ToDictionary(skill => skill, skill => new SnapshotDiff<int>());
/// <summary>Get a list of inventory changes.</summary> /// <summary>Get a list of inventory changes.</summary>
public IEnumerable<ItemStackChange> InventoryChanges { get; private set; } public SnapshotItemListDiff Inventory { get; private set; }
/********* /*********
@ -47,7 +54,11 @@ namespace StardewModdingAPI.Framework.StateTracking.Snapshots
this.Location.Update(watcher.LocationWatcher); this.Location.Update(watcher.LocationWatcher);
foreach (var pair in this.Skills) foreach (var pair in this.Skills)
pair.Value.Update(watcher.SkillWatchers[pair.Key]); pair.Value.Update(watcher.SkillWatchers[pair.Key]);
this.InventoryChanges = watcher.GetInventoryChanges().ToArray();
this.Inventory = watcher.TryGetInventoryChanges(out SnapshotItemListDiff itemChanges)
? itemChanges
: this.EmptyItemListDiff;
} }
} }
} }

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Graphics;
using Netcode;
using StardewModdingAPI.Framework.Reflection; using StardewModdingAPI.Framework.Reflection;
using StardewValley; using StardewValley;
using StardewValley.BellsAndWhistles; using StardewValley.BellsAndWhistles;
@ -11,6 +12,7 @@ using StardewValley.Characters;
using StardewValley.GameData.Movies; using StardewValley.GameData.Movies;
using StardewValley.Locations; using StardewValley.Locations;
using StardewValley.Menus; using StardewValley.Menus;
using StardewValley.Network;
using StardewValley.Objects; using StardewValley.Objects;
using StardewValley.Projectiles; using StardewValley.Projectiles;
using StardewValley.TerrainFeatures; using StardewValley.TerrainFeatures;
@ -65,8 +67,8 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload one of the game's core assets (if applicable).</summary> /// <summary>Reload one of the game's core assets (if applicable).</summary>
/// <param name="content">The content manager through which to reload the asset.</param> /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="assets">The asset keys and types to reload.</param> /// <param name="assets">The asset keys and types to reload.</param>
/// <returns>Returns the number of reloaded assets.</returns> /// <returns>Returns a lookup of asset names to whether they've been propagated.</returns>
public int Propagate(LocalizedContentManager content, IDictionary<string, Type> assets) public IDictionary<string, bool> Propagate(LocalizedContentManager content, IDictionary<string, Type> assets)
{ {
// group into optimized lists // group into optimized lists
var buckets = assets.GroupBy(p => var buckets = assets.GroupBy(p =>
@ -81,25 +83,26 @@ namespace StardewModdingAPI.Metadata
}); });
// reload assets // reload assets
int reloaded = 0; IDictionary<string, bool> propagated = assets.ToDictionary(p => p.Key, p => false, StringComparer.InvariantCultureIgnoreCase);
foreach (var bucket in buckets) foreach (var bucket in buckets)
{ {
switch (bucket.Key) switch (bucket.Key)
{ {
case AssetBucket.Sprite: case AssetBucket.Sprite:
reloaded += this.ReloadNpcSprites(content, bucket.Select(p => p.Key)); this.ReloadNpcSprites(content, bucket.Select(p => p.Key), propagated);
break; break;
case AssetBucket.Portrait: case AssetBucket.Portrait:
reloaded += this.ReloadNpcPortraits(content, bucket.Select(p => p.Key)); this.ReloadNpcPortraits(content, bucket.Select(p => p.Key), propagated);
break; break;
default: default:
reloaded += bucket.Count(p => this.PropagateOther(content, p.Key, p.Value)); foreach (var entry in bucket)
propagated[entry.Key] = this.PropagateOther(content, entry.Key, entry.Value);
break; break;
} }
} }
return reloaded; return propagated;
} }
@ -193,7 +196,7 @@ namespace StardewModdingAPI.Metadata
return true; return true;
case "characters\\farmer\\farmer_girl_base": // Farmer case "characters\\farmer\\farmer_girl_base": // Farmer
case "characters\\farmer\\farmer_girl_bald": case "characters\\farmer\\farmer_girl_base_bald":
if (Game1.player == null || Game1.player.IsMale) if (Game1.player == null || Game1.player.IsMale)
return false; return false;
Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player); Game1.player.FarmerRenderer = new FarmerRenderer(key, Game1.player);
@ -226,6 +229,31 @@ namespace StardewModdingAPI.Metadata
Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key); Game1.bigCraftablesInformation = content.Load<Dictionary<int, string>>(key);
return true; return true;
case "data\\bundles": // NetWorldState constructor
{
var bundles = this.Reflection.GetField<NetBundles>(Game1.netWorldState.Value, "bundles").GetValue();
var rewards = this.Reflection.GetField<NetIntDictionary<bool, NetBool>>(Game1.netWorldState.Value, "bundleRewards").GetValue();
foreach (var pair in content.Load<Dictionary<string, string>>(key))
{
int bundleKey = int.Parse(pair.Key.Split('/')[1]);
int rewardsCount = pair.Value.Split('/')[2].Split(' ').Length;
// add bundles
if (!bundles.TryGetValue(bundleKey, out bool[] values) || values.Length < rewardsCount)
{
values ??= new bool[0];
bundles.Remove(bundleKey);
bundles[bundleKey] = values.Concat(Enumerable.Repeat(false, rewardsCount - values.Length)).ToArray();
}
// add bundle rewards
if (!rewards.ContainsKey(bundleKey))
rewards[bundleKey] = false;
}
}
break;
case "data\\clothinginformation": // Game1.LoadContent case "data\\clothinginformation": // Game1.LoadContent
Game1.clothingInformation = content.Load<Dictionary<int, string>>(key); Game1.clothingInformation = content.Load<Dictionary<int, string>>(key);
return true; return true;
@ -474,10 +502,18 @@ namespace StardewModdingAPI.Metadata
/**** /****
** Content\TerrainFeatures ** Content\TerrainFeatures
****/ ****/
case "terrainfeatures\\flooring": // Flooring case "terrainfeatures\\flooring": // from Flooring
Flooring.floorsTexture = content.Load<Texture2D>(key); Flooring.floorsTexture = content.Load<Texture2D>(key);
return true; return true;
case "terrainfeatures\\flooring_winter": // from Flooring
Flooring.floorsTextureWinter = content.Load<Texture2D>(key);
return true;
case "terrainfeatures\\grass": // from Grass
this.ReloadGrassTextures(content, key);
return true;
case "terrainfeatures\\hoedirt": // from HoeDirt case "terrainfeatures\\hoedirt": // from HoeDirt
HoeDirt.lightTexture = content.Load<Texture2D>(key); HoeDirt.lightTexture = content.Load<Texture2D>(key);
return true; return true;
@ -607,7 +643,7 @@ namespace StardewModdingAPI.Metadata
{ {
// get buildings // get buildings
string type = Path.GetFileName(key); string type = Path.GetFileName(key);
Building[] buildings = Game1.locations Building[] buildings = this.GetLocations(buildingInteriors: false)
.OfType<BuildableGameLocation>() .OfType<BuildableGameLocation>()
.SelectMany(p => p.buildings) .SelectMany(p => p.buildings)
.Where(p => p.buildingType.Value == type) .Where(p => p.buildingType.Value == type)
@ -694,6 +730,35 @@ namespace StardewModdingAPI.Metadata
return true; return true;
} }
/// <summary>Reload tree textures.</summary>
/// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param>
/// <returns>Returns whether any textures were reloaded.</returns>
private bool ReloadGrassTextures(LocalizedContentManager content, string key)
{
Grass[] grasses =
(
from location in this.GetLocations()
from grass in location.terrainFeatures.Values.OfType<Grass>()
let textureName = this.NormalizeAssetNameIgnoringEmpty(
this.Reflection.GetMethod(grass, "textureName").Invoke<string>()
)
where textureName == key
select grass
)
.ToArray();
if (grasses.Any())
{
Lazy<Texture2D> texture = new Lazy<Texture2D>(() => content.Load<Texture2D>(key));
foreach (Grass grass in grasses)
this.Reflection.GetField<Lazy<Texture2D>>(grass, "texture").SetValue(texture);
return true;
}
return false;
}
/// <summary>Reload the disposition data for matching NPCs.</summary> /// <summary>Reload the disposition data for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param> /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="key">The asset key to reload.</param> /// <param name="key">The asset key to reload.</param>
@ -717,51 +782,57 @@ namespace StardewModdingAPI.Metadata
/// <summary>Reload the sprites for matching NPCs.</summary> /// <summary>Reload the sprites for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param> /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset keys to reload.</param> /// <param name="keys">The asset keys to reload.</param>
/// <returns>Returns the number of reloaded assets.</returns> /// <param name="propagated">The asset keys which have been propagated.</param>
private int ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys) private void ReloadNpcSprites(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{ {
// get NPCs // get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase); HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
NPC[] characters = this.GetCharacters() var characters =
.Where(npc => npc.Sprite != null && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name))) (
from npc in this.GetCharacters()
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Sprite?.Texture?.Name)
where key != null && lookup.Contains(key)
select new { Npc = npc, Key = key }
)
.ToArray(); .ToArray();
if (!characters.Any()) if (!characters.Any())
return 0; return;
// update sprite // update sprite
int reloaded = 0; foreach (var target in characters)
foreach (NPC npc in characters)
{ {
this.SetSpriteTexture(npc.Sprite, content.Load<Texture2D>(npc.Sprite.textureName.Value)); this.SetSpriteTexture(target.Npc.Sprite, content.Load<Texture2D>(target.Key));
reloaded++; propagated[target.Key] = true;
} }
return reloaded;
} }
/// <summary>Reload the portraits for matching NPCs.</summary> /// <summary>Reload the portraits for matching NPCs.</summary>
/// <param name="content">The content manager through which to reload the asset.</param> /// <param name="content">The content manager through which to reload the asset.</param>
/// <param name="keys">The asset key to reload.</param> /// <param name="keys">The asset key to reload.</param>
/// <returns>Returns the number of reloaded assets.</returns> /// <param name="propagated">The asset keys which have been propagated.</param>
private int ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys) private void ReloadNpcPortraits(LocalizedContentManager content, IEnumerable<string> keys, IDictionary<string, bool> propagated)
{ {
// get NPCs // get NPCs
HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase); HashSet<string> lookup = new HashSet<string>(keys, StringComparer.InvariantCultureIgnoreCase);
var villagers = this var characters =
.GetCharacters() (
.Where(npc => npc.isVillager() && lookup.Contains(this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name))) from npc in this.GetCharacters()
where npc.isVillager()
let key = this.NormalizeAssetNameIgnoringEmpty(npc.Portrait?.Name)
where key != null && lookup.Contains(key)
select new { Npc = npc, Key = key }
)
.ToArray(); .ToArray();
if (!villagers.Any()) if (!characters.Any())
return 0; return;
// update portrait // update portrait
int reloaded = 0; foreach (var target in characters)
foreach (NPC npc in villagers)
{ {
npc.Portrait = content.Load<Texture2D>(npc.Portrait.Name); target.Npc.Portrait = content.Load<Texture2D>(target.Key);
reloaded++; propagated[target.Key] = true;
} }
return reloaded;
} }
/// <summary>Reload tree textures.</summary> /// <summary>Reload tree textures.</summary>
@ -771,7 +842,7 @@ namespace StardewModdingAPI.Metadata
/// <returns>Returns whether any textures were reloaded.</returns> /// <returns>Returns whether any textures were reloaded.</returns>
private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type)
{ {
Tree[] trees = Game1.locations Tree[] trees = this.GetLocations()
.SelectMany(p => p.terrainFeatures.Values.OfType<Tree>()) .SelectMany(p => p.terrainFeatures.Values.OfType<Tree>())
.Where(tree => tree.treeType.Value == type) .Where(tree => tree.treeType.Value == type)
.ToArray(); .ToArray();
@ -876,7 +947,8 @@ namespace StardewModdingAPI.Metadata
} }
/// <summary>Get all locations in the game.</summary> /// <summary>Get all locations in the game.</summary>
private IEnumerable<GameLocation> GetLocations() /// <param name="buildingInteriors">Whether to also get the interior locations for constructable buildings.</param>
private IEnumerable<GameLocation> GetLocations(bool buildingInteriors = true)
{ {
// get available root locations // get available root locations
IEnumerable<GameLocation> rootLocations = Game1.locations; IEnumerable<GameLocation> rootLocations = Game1.locations;
@ -888,7 +960,7 @@ namespace StardewModdingAPI.Metadata
{ {
yield return location; yield return location;
if (location is BuildableGameLocation buildableLocation) if (buildingInteriors && location is BuildableGameLocation buildableLocation)
{ {
foreach (Building building in buildableLocation.buildings) foreach (Building building in buildableLocation.buildings)
{ {

View File

@ -60,6 +60,7 @@ namespace StardewModdingAPI.Metadata
if (paranoidMode) if (paranoidMode)
{ {
// filesystem access // filesystem access
yield return new TypeFinder(typeof(System.Console).FullName, InstructionHandleResult.DetectedConsoleAccess);
yield return new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess); yield return new TypeFinder(typeof(System.IO.File).FullName, InstructionHandleResult.DetectedFilesystemAccess);
yield return new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess); yield return new TypeFinder(typeof(System.IO.FileStream).FullName, InstructionHandleResult.DetectedFilesystemAccess);
yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess); yield return new TypeFinder(typeof(System.IO.FileInfo).FullName, InstructionHandleResult.DetectedFilesystemAccess);

View File

@ -59,12 +59,6 @@ The default values are mirrored in StardewModdingAPI.Framework.Models.SConfig to
*/ */
"LogNetworkTraffic": false, "LogNetworkTraffic": false,
/**
* Whether to generate a 'SMAPI-latest.metadata-dump.json' file in the logs folder with the full mod
* metadata for detected mods. This is only needed when troubleshooting some cases.
*/
"DumpMetadata": false,
/** /**
* The colors to use for text written to the SMAPI console. * The colors to use for text written to the SMAPI console.
* *

View File

@ -99,9 +99,30 @@
<Link>SMAPI.metadata.json</Link> <Link>SMAPI.metadata.json</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content> </Content>
<None Update="i18n\de.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\es.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\ja.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\default.json"> <None Update="i18n\default.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="i18n\pt.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\ru.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\tr.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="i18n\zh.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="steam_appid.txt"> <None Update="steam_appid.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>

3
src/SMAPI/i18n/es.json Normal file
View File

@ -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)."
}

3
src/SMAPI/i18n/ja.json Normal file
View File

@ -0,0 +1,3 @@
{
"warn.invalid-content-removed": "クラッシュを防ぐために無効なコンテンツを取り除きました 詳細はSMAPIコンソールを参照"
}

3
src/SMAPI/i18n/pt.json Normal file
View File

@ -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)."
}