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