Merge branch 'develop' into stable

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

5
.gitignore vendored
View File

@ -18,6 +18,9 @@ _ReSharper*/
*.[Rr]e[Ss]harper
*.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

View File

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

View File

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

View File

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

View File

@ -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._

View File

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

View File

@ -10,17 +10,21 @@ and update check API.
* [Short URLs](#short-urls)
* [For SMAPI developers](#for-smapi-developers)
* [Local development](#local-development)
* [Deploying to Amazon Beanstalk](#deploying-to-amazon-beanstalk)
* [Production environment](#production-environment)
## Log parser
The log parser provides a web UI for uploading, parsing, and sharing SMAPI logs. The logs are
persisted in a compressed form to Pastebin. The log parser lives at https://smapi.io/log.
The log parser at https://smapi.io/log provides a web UI for uploading, parsing, and sharing SMAPI
logs.
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
## JSON validator
### Overview
The JSON validator provides a web UI for uploading and sharing JSON files, and validating them as
plain JSON or against a predefined format like `manifest.json` or Content Patcher's `content.json`.
The JSON validator lives at https://smapi.io/json.
The JSON validator at https://smapi.io/json provides a web UI for uploading and sharing JSON files,
and validating them as plain JSON or against a predefined format like `manifest.json` or Content
Patcher's `content.json`.
The logs are saved in a compressed form to Amazon Blob storage for 30 days.
### Schema file format
Schema files are defined in `wwwroot/schemas` using the [JSON Schema](https://json-schema.org/)
@ -336,43 +340,44 @@ short url | → | target page
A local environment lets you run a complete copy of the web project (including cache database) on
your machine, with no external dependencies aside from the actual mod sites.
Initial setup:
1. [Install MongoDB](https://docs.mongodb.com/manual/administration/install-community/) and add its
`bin` folder to the system PATH.
2. Create a local folder for the MongoDB data (e.g. `C:\dev\smapi-cache`).
3. Enter your credentials in the `appsettings.Development.json` file. You can leave the MongoDB
credentials as-is to use the default local instance; see the next section for the other settings.
To launch the environment:
1. Launch MongoDB from a terminal (change the data path if applicable):
```sh
mongod --dbpath C:\dev\smapi-cache
```
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
<small>(Local URLs will use HTTP instead of HTTPS.)</small>
1. Enter the Nexus credentials in `appsettings.Development.json` . You can leave the other
credentials empty to default to fetching data anonymously, and storing data in-memory and
on disk.
2. Launch `SMAPI.Web` from Visual Studio to run a local version of the site.
### Production environment
A production environment includes the web servers and cache database hosted online for public
access. This section assumes you're creating a new production environment from scratch (not using
the official live environment).
access.
This section assumes you're creating a new environment on Azure, but the app isn't tied to any
Azure services. If you want to host it on a different site, you'll need to adjust the instructions
accordingly.
Initial setup:
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas)).
2. Create an AWS Beanstalk .NET environment with these environment properties:
1. Launch an empty MongoDB server (e.g. using [MongoDB Atlas](https://www.mongodb.com/cloud/atlas))
for mod data.
2. Create an Azure Blob storage account for uploaded files.
3. Create an Azure App Services environment running the latest .NET Core on Linux or Windows.
4. Add these application settings in the new App Services environment:
property name | description
------------------------------- | -----------------
`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.)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
{

View File

@ -1,15 +0,0 @@
namespace StardewModdingAPI.Web.Framework.Clients.Pastebin
{
/// <summary>The response for a save-log request.</summary>
internal class SavePasteResult
{
/// <summary>Whether the log was successfully saved.</summary>
public bool Success { get; set; }
/// <summary>The saved paste ID (if <see cref="Success"/> is <c>true</c>).</summary>
public string ID { get; set; }
/// <summary>The error message (if saving failed).</summary>
public string Error { get; set; }
}
}

View File

@ -14,19 +14,16 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels
/****
** Amazon Web Services
** Azure
****/
/// <summary>The access key for AWS authentication.</summary>
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; }
}
}

View File

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

View File

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

View File

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

View File

@ -37,7 +37,7 @@ namespace StardewModdingAPI.Web.Framework.LogParsing
private readonly Regex ContentPackListStartPattern = new Regex(@"^Loaded \d+ content packs:$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
/// <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);

View File

@ -0,0 +1,18 @@
using System.Threading.Tasks;
namespace StardewModdingAPI.Web.Framework.Storage
{
/// <summary>Provides access to raw data storage.</summary>
internal interface IStorageProvider
{
/// <summary>Save a text file to storage.</summary>
/// <param name="content">The content to upload.</param>
/// <param name="compress">Whether to gzip the text.</param>
/// <returns>Returns metadata about the save attempt.</returns>
Task<UploadResult> SaveAsync(string content, bool compress = true);
/// <summary>Fetch raw text from storage.</summary>
/// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
Task<StoredFileInfo> GetAsync(string id);
}
}

View File

@ -0,0 +1,181 @@
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Azure;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Options;
using StardewModdingAPI.Web.Framework.Clients.Pastebin;
using StardewModdingAPI.Web.Framework.Compression;
using StardewModdingAPI.Web.Framework.ConfigModels;
namespace StardewModdingAPI.Web.Framework.Storage
{
/// <summary>Provides access to raw data storage.</summary>
internal class StorageProvider : IStorageProvider
{
/*********
** Fields
*********/
/// <summary>The API client settings.</summary>
private readonly ApiClientsConfig ClientsConfig;
/// <summary>The underlying Pastebin client.</summary>
private readonly IPastebinClient Pastebin;
/// <summary>The underlying text compression helper.</summary>
private readonly IGzipHelper GzipHelper;
/// <summary>Whether Azure blob storage is configured.</summary>
private bool HasAzure => !string.IsNullOrWhiteSpace(this.ClientsConfig.AzureBlobConnectionString);
/// <summary>The number of days since the blob's last-modified date when it will be deleted.</summary>
private int ExpiryDays => this.ClientsConfig.AzureBlobTempExpiryDays;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="clientsConfig">The API client settings.</param>
/// <param name="pastebin">The underlying Pastebin client.</param>
/// <param name="gzipHelper">The underlying text compression helper.</param>
public StorageProvider(IOptions<ApiClientsConfig> clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper)
{
this.ClientsConfig = clientsConfig.Value;
this.Pastebin = pastebin;
this.GzipHelper = gzipHelper;
}
/// <summary>Save a text file to storage.</summary>
/// <param name="content">The content to upload.</param>
/// <param name="compress">Whether to gzip the text.</param>
/// <returns>Returns metadata about the save attempt.</returns>
public async Task<UploadResult> SaveAsync(string content, bool compress = true)
{
string id = Guid.NewGuid().ToString("N");
// save to Azure
if (this.HasAzure)
{
try
{
using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(content));
BlobClient blob = this.GetAzureBlobClient(id);
await blob.UploadAsync(stream);
return new UploadResult(true, id, null);
}
catch (Exception ex)
{
return new UploadResult(false, null, ex.Message);
}
}
// save to local filesystem for testing
else
{
string path = this.GetDevFilePath(id);
Directory.CreateDirectory(Path.GetDirectoryName(path));
File.WriteAllText(path, content);
return new UploadResult(true, id, null);
}
}
/// <summary>Fetch raw text from storage.</summary>
/// <param name="id">The storage ID returned by <see cref="SaveAsync"/>.</param>
public async Task<StoredFileInfo> GetAsync(string id)
{
// fetch from blob storage
if (Guid.TryParseExact(id, "N", out Guid _))
{
// Azure Blob storage
if (this.HasAzure)
{
try
{
BlobClient blob = this.GetAzureBlobClient(id);
Response<BlobDownloadInfo> response = await blob.DownloadAsync();
using BlobDownloadInfo result = response.Value;
using StreamReader reader = new StreamReader(result.Content);
DateTimeOffset expiry = result.Details.LastModified + TimeSpan.FromDays(this.ExpiryDays);
string content = this.GzipHelper.DecompressString(reader.ReadToEnd());
return new StoredFileInfo
{
Success = true,
Content = content,
Expiry = expiry.UtcDateTime
};
}
catch (RequestFailedException ex)
{
return new StoredFileInfo
{
Error = ex.ErrorCode == "BlobNotFound"
? "There's no file with that ID."
: $"Could not fetch that file from storage ({ex.ErrorCode}: {ex.Message})."
};
}
}
// local filesystem for testing
else
{
FileInfo file = new FileInfo(this.GetDevFilePath(id));
if (file.Exists)
{
if (file.LastWriteTimeUtc.AddDays(this.ExpiryDays) < DateTime.UtcNow)
file.Delete();
else
{
return new StoredFileInfo
{
Success = true,
Content = File.ReadAllText(file.FullName),
Expiry = DateTime.UtcNow.AddDays(this.ExpiryDays),
Warning = "This file was saved temporarily to the local computer. This should only happen in a local development environment."
};
}
}
return new StoredFileInfo
{
Error = "There's no file with that ID."
};
}
}
// get from Pastebin
else
{
PasteInfo response = await this.Pastebin.GetAsync(id);
response.Content = this.GzipHelper.DecompressString(response.Content);
return new StoredFileInfo
{
Success = response.Success,
Content = response.Content,
Error = response.Error
};
}
}
/// <summary>Get a client for reading and writing to Azure Blob storage.</summary>
/// <param name="id">The file ID.</param>
private BlobClient GetAzureBlobClient(string id)
{
var azure = new BlobServiceClient(this.ClientsConfig.AzureBlobConnectionString);
var container = azure.GetBlobContainerClient(this.ClientsConfig.AzureBlobTempContainer);
return container.GetBlobClient($"uploads/{id}");
}
/// <summary>Get the absolute file path for an upload when running in a local test environment with no Azure account configured.</summary>
/// <param name="id">The file ID.</param>
private string GetDevFilePath(string id)
{
return Path.Combine(Path.GetTempPath(), "smapi-web-temp", $"{id}.txt");
}
}
}

View File

@ -0,0 +1,23 @@
using System;
namespace StardewModdingAPI.Web.Framework.Storage
{
/// <summary>The response for a get-file request.</summary>
internal class StoredFileInfo
{
/// <summary>Whether the file was successfully fetched.</summary>
public bool Success { get; set; }
/// <summary>The fetched file content (if <see cref="Success"/> is <c>true</c>).</summary>
public string Content { get; set; }
/// <summary>When the file will no longer be available.</summary>
public DateTime? Expiry { get; set; }
/// <summary>The error message if saving succeeded, but a non-blocking issue was encountered.</summary>
public string Warning { get; set; }
/// <summary>The error message if saving failed.</summary>
public string Error { get; set; }
}
}

View File

@ -0,0 +1,33 @@
namespace StardewModdingAPI.Web.Framework.Storage
{
/// <summary>The result of an attempt to upload a file.</summary>
internal class UploadResult
{
/*********
** Accessors
*********/
/// <summary>Whether the file upload succeeded.</summary>
public bool Succeeded { get; }
/// <summary>The file ID, if applicable.</summary>
public string ID { get; }
/// <summary>The upload error, if any.</summary>
public string UploadError { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="succeeded">Whether the file upload succeeded.</param>
/// <param name="id">The file ID, if applicable.</param>
/// <param name="uploadError">The upload error, if any.</param>
public UploadResult(bool succeeded, string id, string uploadError)
{
this.Succeeded = succeeded;
this.ID = id;
this.UploadError = uploadError;
}
}
}

View File

@ -12,8 +12,9 @@
</ItemGroup>
<ItemGroup>
<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" />

View File

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

View File

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

View File

@ -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)
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": {

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,48 @@
using System;
using System.Collections.Generic;
using StardewValley;
using StardewValley.Objects;
namespace StardewModdingAPI.Events
{
/// <summary>Event arguments for a <see cref="IWorldEvents.ChestInventoryChanged"/> event.</summary>
public class ChestInventoryChangedEventArgs : EventArgs
{
/*********
** Accessors
*********/
/// <summary>The chest whose inventory changed.</summary>
public Chest Chest { get; }
/// <summary>The location containing the chest.</summary>
public GameLocation Location { get; }
/// <summary>The added item stacks.</summary>
public IEnumerable<Item> Added { get; }
/// <summary>The removed item stacks.</summary>
public IEnumerable<Item> Removed { get; }
/// <summary>The item stacks whose size changed.</summary>
public IEnumerable<ItemStackSizeChange> QuantityChanged { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="chest">The chest whose inventory changed.</param>
/// <param name="location">The location containing the chest.</param>
/// <param name="added">The added item stacks.</param>
/// <param name="removed">The removed item stacks.</param>
/// <param name="quantityChanged">The item stacks whose size changed.</param>
internal ChestInventoryChangedEventArgs(Chest chest, GameLocation location, Item[] added, Item[] removed, ItemStackSizeChange[] quantityChanged)
{
this.Location = location;
this.Chest = chest;
this.Added = added;
this.Removed = removed;
this.QuantityChanged = quantityChanged;
}
}
}

View File

@ -23,6 +23,9 @@ namespace StardewModdingAPI.Events
/// <summary>Raised after objects are added or removed in a location.</summary>
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;
}

View File

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

View File

@ -1,20 +0,0 @@
using StardewValley;
namespace StardewModdingAPI.Events
{
/// <summary>Represents an inventory slot that changed.</summary>
public class ItemStackChange
{
/*********
** Accessors
*********/
/// <summary>The item in the slot.</summary>
public Item Item { get; set; }
/// <summary>The amount by which the item's stack size changed.</summary>
public int StackChange { get; set; }
/// <summary>How the inventory slot changed.</summary>
public ChangeType ChangeType { get; set; }
}
}

View File

@ -42,8 +42,8 @@ namespace StardewModdingAPI.Framework.Content
Texture2D target = this.Data;
// 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)

View File

@ -0,0 +1,93 @@
using System;
using System.Reflection;
namespace StardewModdingAPI.Framework.Content
{
/// <summary>A wrapper for <see cref="IAssetEditor"/> and <see cref="IAssetLoader"/> for internal cache invalidation.</summary>
internal class AssetInterceptorChange
{
/*********
** Accessors
*********/
/// <summary>The mod which registered the interceptor.</summary>
public IModMetadata Mod { get; }
/// <summary>The interceptor instance.</summary>
public object Instance { get; }
/// <summary>Whether the asset interceptor was added since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
public bool WasAdded { get; }
/// <summary>Whether the asset interceptor was removed since the last tick. Mutually exclusive with <see cref="WasRemoved"/>.</summary>
public bool WasRemoved => this.WasAdded;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="mod">The mod registering the interceptor.</param>
/// <param name="instance">The interceptor. This must be an <see cref="IAssetEditor"/> or <see cref="IAssetLoader"/> instance.</param>
/// <param name="wasAdded">Whether the asset interceptor was added since the last tick; else removed.</param>
public AssetInterceptorChange(IModMetadata mod, object instance, bool wasAdded)
{
this.Mod = mod ?? throw new ArgumentNullException(nameof(mod));
this.Instance = instance ?? throw new ArgumentNullException(nameof(instance));
this.WasAdded = wasAdded;
if (!(instance is IAssetEditor) && !(instance is IAssetLoader))
throw new InvalidCastException($"The provided {nameof(instance)} value must be an {nameof(IAssetEditor)} or {nameof(IAssetLoader)} instance.");
}
/// <summary>Get whether this instance can intercept the given asset.</summary>
/// <param name="asset">Basic metadata about the asset being loaded.</param>
public bool CanIntercept(IAssetInfo asset)
{
MethodInfo canIntercept = this.GetType().GetMethod(nameof(this.CanInterceptImpl), BindingFlags.Instance | BindingFlags.NonPublic);
if (canIntercept == null)
throw new InvalidOperationException($"SMAPI couldn't access the {nameof(AssetInterceptorChange)}.{nameof(this.CanInterceptImpl)} implementation.");
return (bool)canIntercept.MakeGenericMethod(asset.DataType).Invoke(this, new object[] { asset });
}
/*********
** Private methods
*********/
/// <summary>Get whether this instance can intercept the given asset.</summary>
/// <typeparam name="TAsset">The asset type.</typeparam>
/// <param name="asset">Basic metadata about the asset being loaded.</param>
private bool CanInterceptImpl<TAsset>(IAssetInfo asset)
{
// check edit
if (this.Instance is IAssetEditor editor)
{
try
{
if (editor.CanEdit<TAsset>(asset))
return true;
}
catch (Exception ex)
{
this.Mod.LogAsMod($"Mod failed when checking whether it could edit asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
// check load
if (this.Instance is IAssetLoader loader)
{
try
{
if (loader.CanLoad<TAsset>(asset))
return true;
}
catch (Exception ex)
{
this.Mod.LogAsMod($"Mod failed when checking whether it could load asset '{asset.AssetName}'. Error details:\n{ex.GetLogSummary()}", LogLevel.Error);
}
}
return false;
}
}
}

View File

@ -119,13 +119,12 @@ namespace StardewModdingAPI.Framework.Content
/// <param name="predicate">Matches the asset keys to invalidate.</param>
/// <param name="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);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
namespace StardewModdingAPI.Framework.Models
{
/// <summary>Metadata exported to the mod folder.</summary>
internal class ModFolderExport
{
/// <summary>When the export was generated.</summary>
public string Exported { get; set; }
/// <summary>The absolute path of the mod folder.</summary>
public string ModFolderPath { get; set; }
/// <summary>The game version which last loaded the mods.</summary>
public string GameVersion { get; set; }
/// <summary>The SMAPI version which last loaded the mods.</summary>
public string ApiVersion { get; set; }
/// <summary>The detected mods.</summary>
public IModMetadata[] Mods { get; set; }
}
}

View File

@ -25,8 +25,7 @@ namespace StardewModdingAPI.Framework.Models
[nameof(GitHubProjectName)] = "Pathoschild/SMAPI",
[nameof(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; }

View File

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

View File

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

View File

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

View File

@ -0,0 +1,66 @@
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Events;
using StardewValley;
namespace StardewModdingAPI.Framework
{
/// <summary>A snapshot of a tracked item list.</summary>
internal class SnapshotItemListDiff
{
/*********
** Accessors
*********/
/// <summary>Whether the item list changed.</summary>
public bool IsChanged { get; }
/// <summary>The removed values.</summary>
public Item[] Removed { get; }
/// <summary>The added values.</summary>
public Item[] Added { get; }
/// <summary>The items whose stack sizes changed.</summary>
public ItemStackSizeChange[] QuantityChanged { get; }
/*********
** Public methods
*********/
/// <summary>Update the snapshot.</summary>
/// <param name="added">The added values.</param>
/// <param name="removed">The removed values.</param>
/// <param name="sizesChanged">The items whose stack sizes changed.</param>
public SnapshotItemListDiff(Item[] added, Item[] removed, ItemStackSizeChange[] sizesChanged)
{
this.Removed = removed;
this.Added = added;
this.QuantityChanged = sizesChanged;
this.IsChanged = removed.Length > 0 || added.Length > 0 || sizesChanged.Length > 0;
}
/// <summary>Get a snapshot diff if anything changed in the given data.</summary>
/// <param name="added">The added item stacks.</param>
/// <param name="removed">The removed item stacks.</param>
/// <param name="stackSizes">The items with their previous stack sizes.</param>
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
public static bool TryGetChanges(ISet<Item> added, ISet<Item> removed, IDictionary<Item, int> stackSizes, out SnapshotItemListDiff changes)
{
KeyValuePair<Item, int>[] sizesChanged = stackSizes.Where(p => p.Key.Stack != p.Value).ToArray();
if (sizesChanged.Any() || added.Any() || removed.Any())
{
changes = new SnapshotItemListDiff(
added: added.ToArray(),
removed: removed.ToArray(),
sizesChanged: sizesChanged.Select(p => new ItemStackSizeChange(p.Key, p.Value, p.Key.Stack)).ToArray()
);
return true;
}
changes = null;
return false;
}
}
}

View File

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StardewModdingAPI.Framework.StateTracking.Comparers;
using StardewModdingAPI.Framework.StateTracking.FieldWatchers;
using StardewValley;
using StardewValley.Objects;
namespace StardewModdingAPI.Framework.StateTracking
{
/// <summary>Tracks changes to a chest's items.</summary>
internal class ChestTracker : IDisposable
{
/*********
** Fields
*********/
/// <summary>The item stack sizes as of the last update.</summary>
private readonly IDictionary<Item, int> StackSizes;
/// <summary>Items added since the last update.</summary>
private readonly HashSet<Item> Added = new HashSet<Item>(new ObjectReferenceComparer<Item>());
/// <summary>Items removed since the last update.</summary>
private readonly HashSet<Item> Removed = new HashSet<Item>(new ObjectReferenceComparer<Item>());
/// <summary>The underlying inventory watcher.</summary>
private readonly ICollectionWatcher<Item> InventoryWatcher;
/*********
** Accessors
*********/
/// <summary>The chest being tracked.</summary>
public Chest Chest { get; }
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="chest">The chest being tracked.</param>
public ChestTracker(Chest chest)
{
this.Chest = chest;
this.InventoryWatcher = WatcherFactory.ForNetList(chest.items);
this.StackSizes = this.Chest.items
.Where(n => n != null)
.Distinct()
.ToDictionary(n => n, n => n.Stack);
}
/// <summary>Update the current values if needed.</summary>
public void Update()
{
// update watcher
this.InventoryWatcher.Update();
foreach (Item item in this.InventoryWatcher.Added)
this.Added.Add(item);
foreach (Item item in this.InventoryWatcher.Removed)
{
if (!this.Added.Remove(item)) // item didn't change if it was both added and removed, so remove it from both lists
this.Removed.Add(item);
}
// stop tracking removed stacks
foreach (Item item in this.Removed)
this.StackSizes.Remove(item);
}
/// <summary>Reset all trackers so their current values are the baseline.</summary>
public void Reset()
{
// update stack sizes
foreach (Item item in this.StackSizes.Keys.ToArray().Concat(this.Added))
this.StackSizes[item] = item.Stack;
// update watcher
this.InventoryWatcher.Reset();
this.Added.Clear();
this.Removed.Clear();
}
/// <summary>Get the inventory changes since the last update, if anything changed.</summary>
/// <param name="changes">The inventory changes, or <c>null</c> if nothing changed.</param>
/// <returns>Returns whether anything changed.</returns>
public bool TryGetInventoryChanges(out SnapshotItemListDiff changes)
{
return SnapshotItemListDiff.TryGetChanges(added: this.Added, removed: this.Removed, stackSizes: this.StackSizes, out changes);
}
/// <summary>Release watchers and resources.</summary>
public void Dispose()
{
this.StackSizes.Clear();
this.Added.Clear();
this.Removed.Clear();
this.InventoryWatcher.Dispose();
}
}
}

View File

@ -0,0 +1,143 @@
using System.Collections.Generic;
using Netcode;
using StardewModdingAPI.Framework.StateTracking.Comparers;
namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
{
/// <summary>A watcher which detects changes to a net list field.</summary>
/// <typeparam name="TValue">The list value type.</typeparam>
internal class NetListWatcher<TValue> : BaseDisposableWatcher, ICollectionWatcher<TValue>
where TValue : class, INetObject<INetSerializable>
{
/*********
** Fields
*********/
/// <summary>The field being watched.</summary>
private readonly NetList<TValue, NetRef<TValue>> Field;
/// <summary>The pairs added since the last reset.</summary>
private readonly ISet<TValue> AddedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
/// <summary>The pairs removed since the last reset.</summary>
private readonly ISet<TValue> RemovedImpl = new HashSet<TValue>(new ObjectReferenceComparer<TValue>());
/*********
** Accessors
*********/
/// <summary>Whether the collection changed since the last reset.</summary>
public bool IsChanged => this.AddedImpl.Count > 0 || this.RemovedImpl.Count > 0;
/// <summary>The values added since the last reset.</summary>
public IEnumerable<TValue> Added => this.AddedImpl;
/// <summary>The values removed since the last reset.</summary>
public IEnumerable<TValue> Removed => this.RemovedImpl;
/*********
** Public methods
*********/
/// <summary>Construct an instance.</summary>
/// <param name="field">The field to watch.</param>
public NetListWatcher(NetList<TValue, NetRef<TValue>> field)
{
this.Field = field;
field.OnElementChanged += this.OnElementChanged;
field.OnArrayReplaced += this.OnArrayReplaced;
}
/// <summary>Set the current value as the baseline.</summary>
public void Reset()
{
this.AddedImpl.Clear();
this.RemovedImpl.Clear();
}
/// <summary>Update the current value if needed.</summary>
public void Update()
{
this.AssertNotDisposed();
}
/// <summary>Stop watching the field and release all references.</summary>
public override void Dispose()
{
if (!this.IsDisposed)
{
this.Field.OnElementChanged -= this.OnElementChanged;
this.Field.OnArrayReplaced -= this.OnArrayReplaced;
}
base.Dispose();
}
/*********
** Private methods
*********/
/// <summary>A callback invoked when the value list is replaced.</summary>
/// <param name="list">The net field whose values changed.</param>
/// <param name="oldValues">The previous list of values.</param>
/// <param name="newValues">The new list of values.</param>
private void OnArrayReplaced(NetList<TValue, NetRef<TValue>> list, IList<TValue> oldValues, IList<TValue> newValues)
{
ISet<TValue> oldSet = new HashSet<TValue>(oldValues, new ObjectReferenceComparer<TValue>());
ISet<TValue> changed = new HashSet<TValue>(newValues, new ObjectReferenceComparer<TValue>());
foreach (TValue value in oldSet)
{
if (!changed.Contains(value))
this.Remove(value);
}
foreach (TValue value in changed)
{
if (!oldSet.Contains(value))
this.Add(value);
}
}
/// <summary>A callback invoked when an entry is replaced.</summary>
/// <param name="list">The net field whose values changed.</param>
/// <param name="index">The list index which changed.</param>
/// <param name="oldValue">The previous value.</param>
/// <param name="newValue">The new value.</param>
private void OnElementChanged(NetList<TValue, NetRef<TValue>> list, int index, TValue oldValue, TValue newValue)
{
this.Remove(oldValue);
this.Add(newValue);
}
/// <summary>Track an added item.</summary>
/// <param name="value">The value that was added.</param>
private void Add(TValue value)
{
if (value == null)
return;
if (this.RemovedImpl.Contains(value))
{
this.AddedImpl.Remove(value);
this.RemovedImpl.Remove(value);
}
else
this.AddedImpl.Add(value);
}
/// <summary>Track a removed item.</summary>
/// <param name="value">The value that was removed.</param>
private void Remove(TValue value)
{
if (value == null)
return;
if (this.AddedImpl.Contains(value))
{
this.AddedImpl.Remove(value);
this.RemovedImpl.Remove(value);
}
else
this.RemovedImpl.Add(value);
}
}
}

View File

@ -21,6 +21,9 @@ namespace StardewModdingAPI.Framework.StateTracking.FieldWatchers
/// <summary>The pairs removed since the last reset.</summary>
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);
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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)
{

View File

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

View File

@ -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.
*

View File

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

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

@ -0,0 +1,3 @@
{
"warn.invalid-content-removed": "Se ha quitado contenido inválido para evitar un cierre forzoso (revisa la consola de SMAPI para más información)."
}

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

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

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

@ -0,0 +1,3 @@
{
"warn.invalid-content-removed": "Conteúdo inválido foi removido para prevenir uma falha (veja o console do SMAPI para mais informações)."
}

View File

@ -1,3 +1,3 @@
{
"warn.invalid-content-removed": "非法内容已移除以防游戏闪退查看SMAPI控制台获得更多信息"
}
{
"warn.invalid-content-removed": "非法内容已移除以防游戏闪退查看SMAPI控制台获得更多信息"
}