From 0aad3f545af854619c641dc8b57ac0cf12971c8e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 3 Dec 2019 19:36:56 -0500 Subject: update Content Patcher schema --- src/SMAPI.Web/wwwroot/schemas/content-patcher.json | 23 +++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json index 61a633cb..c0236f1e 100644 --- a/src/SMAPI.Web/wwwroot/schemas/content-patcher.json +++ b/src/SMAPI.Web/wwwroot/schemas/content-patcher.json @@ -11,9 +11,9 @@ "title": "Format version", "description": "The format version. You should always use the latest version to enable the latest features and avoid obsolete behavior.", "type": "string", - "const": "1.9", + "const": "1.10.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.10.0'." } }, "ConfigSchema": { @@ -194,6 +194,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 +261,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.", @@ -300,7 +310,7 @@ }, "then": { "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "Fields", "Entries", "MoveEntries" ] + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "Fields", "Entries", "MoveEntries" ] } } }, @@ -313,7 +323,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 +333,8 @@ } }, "propertyNames": { - "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea" ] - }, - "required": [ "FromFile", "ToArea" ] + "enum": [ "Action", "Target", "LogName", "Enabled", "When", "FromFile", "FromArea", "ToArea", "MapProperties" ] + } } } ], -- cgit From 2b1f607d41b3d4d071c0db0671dbc99b6982909f Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 3 Dec 2019 21:21:28 -0500 Subject: encapsulate file storage, also handle Pastebin rate limits in JSON validator --- .../Controllers/JsonValidatorController.cs | 58 +++---- src/SMAPI.Web/Controllers/LogParserController.cs | 178 ++------------------- .../Framework/Clients/Pastebin/PasteInfo.cs | 6 - .../Framework/ConfigModels/ApiClientsConfig.cs | 2 +- .../Framework/Storage/IStorageProvider.cs | 19 +++ src/SMAPI.Web/Framework/Storage/StorageProvider.cs | 147 +++++++++++++++++ src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs | 23 +++ src/SMAPI.Web/Framework/Storage/UploadResult.cs | 33 ++++ src/SMAPI.Web/Startup.cs | 10 +- .../ViewModels/JsonValidator/JsonValidatorModel.cs | 19 ++- src/SMAPI.Web/Views/JsonValidator/Index.cshtml | 15 +- src/SMAPI.Web/appsettings.json | 2 +- .../wwwroot/Content/css/json-validator.css | 6 + 13 files changed, 306 insertions(+), 212 deletions(-) create mode 100644 src/SMAPI.Web/Framework/Storage/IStorageProvider.cs create mode 100644 src/SMAPI.Web/Framework/Storage/StorageProvider.cs create mode 100644 src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs create mode 100644 src/SMAPI.Web/Framework/Storage/UploadResult.cs (limited to 'src') diff --git a/src/SMAPI.Web/Controllers/JsonValidatorController.cs b/src/SMAPI.Web/Controllers/JsonValidatorController.cs index 40599abc..830fe839 100644 --- a/src/SMAPI.Web/Controllers/JsonValidatorController.cs +++ b/src/SMAPI.Web/Controllers/JsonValidatorController.cs @@ -9,8 +9,7 @@ using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Newtonsoft.Json.Schema; using StardewModdingAPI.Web.Framework; -using StardewModdingAPI.Web.Framework.Clients.Pastebin; -using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.ViewModels.JsonValidator; namespace StardewModdingAPI.Web.Controllers @@ -21,11 +20,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// The underlying Pastebin client. - private readonly IPastebinClient Pastebin; - - /// The underlying text compression helper. - private readonly IGzipHelper GzipHelper; + /// Provides access to raw data storage. + private readonly IStorageProvider Storage; /// The supported JSON schemas (names indexed by ID). private readonly IDictionary SchemaFormats = new Dictionary @@ -49,12 +45,10 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// Construct an instance. - /// The Pastebin API client. - /// The underlying text compression helper. - public JsonValidatorController(IPastebinClient pastebin, IGzipHelper gzipHelper) + /// Provides access to raw data storage. + public JsonValidatorController(IStorageProvider storage) { - this.Pastebin = pastebin; - this.GzipHelper = gzipHelper; + this.Storage = storage; } /*** @@ -62,7 +56,7 @@ namespace StardewModdingAPI.Web.Controllers ***/ /// Render the schema validator UI. /// The schema name with which to validate the JSON. - /// The paste ID. + /// The stored file ID. [HttpGet] [Route("json")] [Route("json/{schemaName}")] @@ -76,16 +70,16 @@ 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); // 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 +91,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 +126,20 @@ namespace StardewModdingAPI.Web.Controllers public async Task PostAsync(JsonValidatorRequestModel request) { if (request == null) - return this.View("Index", new JsonValidatorModel(null, null, this.SchemaFormats).SetUploadError("The request seems to be invalid.")); + return this.View("Index", this.GetModel(null, null).SetUploadError("The request seems to be invalid.")); // normalize schema name string schemaName = this.NormalizeSchemaName(request.SchemaName); - // get raw log text + // get raw text string input = request.Content; if (string.IsNullOrWhiteSpace(input)) - return this.View("Index", new JsonValidatorModel(null, schemaName, this.SchemaFormats).SetUploadError("The JSON file seems to be empty.")); - - // upload log - input = this.GzipHelper.CompressString(input); - SavePasteResult result = await this.Pastebin.PostAsync($"JSON validator {DateTime.UtcNow:s}", input); + return this.View("Index", this.GetModel(null, schemaName).SetUploadError("The JSON file seems to be empty.")); - // handle errors - if (!result.Success) - return this.View("Index", new JsonValidatorModel(result.ID, schemaName, this.SchemaFormats).SetUploadError($"Pastebin error: {result.Error ?? "unknown error"}")); + // upload file + var result = await this.Storage.SaveAsync(title: $"JSON validator {DateTime.UtcNow:s}", content: input, compress: true); + 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 +149,12 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Private methods *********/ - /// Fetch raw text from Pastebin. - /// The Pastebin paste ID. - private async Task GetAsync(string id) + /// Build a JSON validator model. + /// The stored file ID. + /// The schema name with which the JSON was validated. + 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); } /// Get a normalized schema name, or the if blank. diff --git a/src/SMAPI.Web/Controllers/LogParserController.cs b/src/SMAPI.Web/Controllers/LogParserController.cs index 318b34d0..e270ae0a 100644 --- a/src/SMAPI.Web/Controllers/LogParserController.cs +++ b/src/SMAPI.Web/Controllers/LogParserController.cs @@ -1,22 +1,12 @@ using System; -using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; -using Amazon.S3; -using Amazon.S3.Model; -using Amazon.S3.Transfer; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; using StardewModdingAPI.Toolkit.Utilities; using StardewModdingAPI.Web.Framework; -using StardewModdingAPI.Web.Framework.Clients.Pastebin; -using StardewModdingAPI.Web.Framework.Compression; -using StardewModdingAPI.Web.Framework.ConfigModels; using StardewModdingAPI.Web.Framework.LogParsing; using StardewModdingAPI.Web.Framework.LogParsing.Models; +using StardewModdingAPI.Web.Framework.Storage; using StardewModdingAPI.Web.ViewModels; namespace StardewModdingAPI.Web.Controllers @@ -27,14 +17,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Fields *********/ - /// The API client settings. - private readonly ApiClientsConfig ClientsConfig; - - /// The underlying Pastebin client. - private readonly IPastebinClient Pastebin; - - /// The underlying text compression helper. - private readonly IGzipHelper GzipHelper; + /// Provides access to raw data storage. + private readonly IStorageProvider Storage; /********* @@ -44,21 +28,17 @@ namespace StardewModdingAPI.Web.Controllers ** Constructor ***/ /// Construct an instance. - /// The API client settings. - /// The Pastebin API client. - /// The underlying text compression helper. - public LogParserController(IOptions clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + /// Provides access to raw data storage. + public LogParserController(IStorageProvider storage) { - this.ClientsConfig = clientsConfig.Value; - this.Pastebin = pastebin; - this.GzipHelper = gzipHelper; + this.Storage = storage; } /*** ** Web UI ***/ /// Render the log parser UI. - /// The paste ID. + /// The stored file ID. /// Whether to display the raw unparsed log. [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(title: $"SMAPI log {DateTime.UtcNow:s}", content: input, compress: true); if (!uploadResult.Succeeded) return this.View("Index", this.GetModel(null, uploadError: uploadResult.UploadError)); @@ -105,106 +84,8 @@ namespace StardewModdingAPI.Web.Controllers /********* ** Private methods *********/ - /// Fetch raw text from Pastebin. - /// The Pastebin paste ID. - private async Task 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; - } - } - - /// Save a log to Pastebin or Amazon S3, if available. - /// The content to upload. - /// Returns metadata about the save attempt. - private async Task 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}"); - } - } - /// Build a log parser model. - /// The paste ID. + /// The stored file ID. /// When the uploaded file will no longer be available. /// A non-blocking warning while uploading the log. /// An error which occurred while uploading the log. @@ -243,36 +124,5 @@ namespace StardewModdingAPI.Web.Controllers return null; } } - - /// The result of an attempt to upload a file. - private class UploadResult - { - /********* - ** Accessors - *********/ - /// Whether the file upload succeeded. - public bool Succeeded { get; } - - /// The file ID, if applicable. - public string ID { get; } - - /// The upload error, if any. - public string UploadError { get; } - - - /********* - ** Public methods - *********/ - /// Construct an instance. - /// Whether the file upload succeeded. - /// The file ID, if applicable. - /// The upload error, if any. - public UploadResult(bool succeeded, string id, string uploadError) - { - this.Succeeded = succeeded; - this.ID = id; - this.UploadError = uploadError; - } - } } } diff --git a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs index bb2de356..1ef3ef12 100644 --- a/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs +++ b/src/SMAPI.Web/Framework/Clients/Pastebin/PasteInfo.cs @@ -11,12 +11,6 @@ namespace StardewModdingAPI.Web.Framework.Clients.Pastebin /// The fetched paste content (if is true). public string Content { get; set; } - /// When the file will no longer be available. - public DateTime? Expiry { get; set; } - - /// The error message if saving succeeded, but a non-blocking issue was encountered. - public string Warning { get; set; } - /// The error message if saving failed. public string Error { get; set; } } diff --git a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs index 7119ef03..1e020840 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/ApiClientsConfig.cs @@ -26,7 +26,7 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels public string AmazonRegion { get; set; } /// The AWS bucket in which to store temporary uploaded logs. - public string AmazonLogBucket { get; set; } + public string AmazonTempBucket { get; set; } /**** diff --git a/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs new file mode 100644 index 00000000..e222a235 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/IStorageProvider.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// Provides access to raw data storage. + internal interface IStorageProvider + { + /// Save a text file to Pastebin or Amazon S3, if available. + /// The display title, if applicable. + /// The content to upload. + /// Whether to gzip the text. + /// Returns metadata about the save attempt. + Task SaveAsync(string title, string content, bool compress = true); + + /// Fetch raw text from storage. + /// The storage ID returned by . + Task GetAsync(string id); + } +} diff --git a/src/SMAPI.Web/Framework/Storage/StorageProvider.cs b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs new file mode 100644 index 00000000..bbb6e06b --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/StorageProvider.cs @@ -0,0 +1,147 @@ +using System; +using System.IO; +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.Extensions.Options; +using StardewModdingAPI.Web.Framework.Clients.Pastebin; +using StardewModdingAPI.Web.Framework.Compression; +using StardewModdingAPI.Web.Framework.ConfigModels; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// Provides access to raw data storage. + internal class StorageProvider : IStorageProvider + { + /********* + ** Fields + *********/ + /// The API client settings. + private readonly ApiClientsConfig ClientsConfig; + + /// The underlying Pastebin client. + private readonly IPastebinClient Pastebin; + + /// The underlying text compression helper. + private readonly IGzipHelper GzipHelper; + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// The API client settings. + /// The underlying Pastebin client. + /// The underlying text compression helper. + public StorageProvider(IOptions clientsConfig, IPastebinClient pastebin, IGzipHelper gzipHelper) + { + this.ClientsConfig = clientsConfig.Value; + this.Pastebin = pastebin; + this.GzipHelper = gzipHelper; + } + + /// Save a text file to Pastebin or Amazon S3, if available. + /// The display title, if applicable. + /// The content to upload. + /// Whether to gzip the text. + /// Returns metadata about the save attempt. + public async Task SaveAsync(string title, string content, bool compress = true) + { + // save to PasteBin + string uploadError; + { + SavePasteResult result = await this.Pastebin.PostAsync(title, 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.AmazonTempBucket, + Key = $"uploads/{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}"); + } + } + + /// Fetch raw text from storage. + /// The storage ID returned by . + public async Task 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.AmazonTempBucket, $"uploads/{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 StoredFileInfo + { + Success = true, + Content = content, + Expiry = expiry, + Warning = pastebinError + }; + } + catch (AmazonServiceException ex) + { + return ex.ErrorCode == "NoSuchKey" + ? new StoredFileInfo { Error = "There's no file with that ID." } + : new StoredFileInfo { Error = $"Could not fetch that file 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 new StoredFileInfo + { + Success = response.Success, + Content = response.Content, + Error = response.Error + }; + } + } + } +} diff --git a/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs new file mode 100644 index 00000000..30676c88 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/StoredFileInfo.cs @@ -0,0 +1,23 @@ +using System; + +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// The response for a get-file request. + internal class StoredFileInfo + { + /// Whether the file was successfully fetched. + public bool Success { get; set; } + + /// The fetched file content (if is true). + public string Content { get; set; } + + /// When the file will no longer be available. + public DateTime? Expiry { get; set; } + + /// The error message if saving succeeded, but a non-blocking issue was encountered. + public string Warning { get; set; } + + /// The error message if saving failed. + public string Error { get; set; } + } +} diff --git a/src/SMAPI.Web/Framework/Storage/UploadResult.cs b/src/SMAPI.Web/Framework/Storage/UploadResult.cs new file mode 100644 index 00000000..483c1769 --- /dev/null +++ b/src/SMAPI.Web/Framework/Storage/UploadResult.cs @@ -0,0 +1,33 @@ +namespace StardewModdingAPI.Web.Framework.Storage +{ + /// The result of an attempt to upload a file. + internal class UploadResult + { + /********* + ** Accessors + *********/ + /// Whether the file upload succeeded. + public bool Succeeded { get; } + + /// The file ID, if applicable. + public string ID { get; } + + /// The upload error, if any. + public string UploadError { get; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + /// Whether the file upload succeeded. + /// The file ID, if applicable. + /// The upload error, if any. + public UploadResult(bool succeeded, string id, string uploadError) + { + this.Succeeded = succeeded; + this.ID = id; + this.UploadError = uploadError; + } + } +} diff --git a/src/SMAPI.Web/Startup.cs b/src/SMAPI.Web/Startup.cs index 53823771..31b5e61d 100644 --- a/src/SMAPI.Web/Startup.cs +++ b/src/SMAPI.Web/Startup.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Rewrite; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; using MongoDB.Bson.Serialization; using MongoDB.Driver; using Newtonsoft.Json; @@ -24,6 +25,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 { @@ -158,7 +160,13 @@ namespace StardewModdingAPI.Web } // init helpers - services.AddSingleton(new GzipHelper()); + services + .AddSingleton(new GzipHelper()) + .AddSingleton(serv => new StorageProvider( + serv.GetRequiredService>(), + serv.GetRequiredService(), + serv.GetRequiredService() + )); } /// The method called by the runtime to configure the HTTP request pipeline. diff --git a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs index 5b18331f..c0dd7184 100644 --- a/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs +++ b/src/SMAPI.Web/ViewModels/JsonValidator/JsonValidatorModel.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.Linq; @@ -24,7 +25,13 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /// The schema validation errors, if any. public JsonValidatorErrorModel[] Errors { get; set; } = new JsonValidatorErrorModel[0]; - /// An error which occurred while uploading the JSON to Pastebin. + /// A non-blocking warning while uploading the file. + public string UploadWarning { get; set; } + + /// When the uploaded file will no longer be available. + public DateTime? Expiry { get; set; } + + /// An error which occurred while uploading the JSON. public string UploadError { get; set; } /// An error which occurred while parsing the JSON. @@ -41,7 +48,7 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator public JsonValidatorModel() { } /// Construct an instance. - /// The paste ID. + /// The stored file ID. /// The schema name with which the JSON was validated. /// The supported JSON schemas (names indexed by ID). public JsonValidatorModel(string pasteID, string schemaName, IDictionary schemaFormats) @@ -53,14 +60,18 @@ namespace StardewModdingAPI.Web.ViewModels.JsonValidator /// Set the validated content. /// The validated content. - public JsonValidatorModel SetContent(string content) + /// When the uploaded file will no longer be available. + /// A non-blocking warning while uploading the log. + public JsonValidatorModel SetContent(string content, DateTime? expiry, string uploadWarning = null) { this.Content = content; + this.Expiry = expiry; + this.UploadWarning = uploadWarning; return this; } - /// Set the error which occurred while uploading the log to Pastebin. + /// Set the error which occurred while uploading the JSON. /// The error message. public JsonValidatorModel SetUploadError(string error) { diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index de6b06a2..a5a134ac 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -1,3 +1,4 @@ +@using Humanizer @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.ViewModels.JsonValidator @model JsonValidatorModel @@ -26,7 +27,7 @@ { } - + @@ -67,6 +68,18 @@ else if (Model.PasteID != null) } +@* save warnings *@ +@if (Model.UploadWarning != null || Model.Expiry != null) +{ + +} + @* upload new file *@ @if (Model.Content == null) { diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index f81587ef..b3567469 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -26,7 +26,7 @@ "AmazonAccessKey": null, "AmazonSecretKey": null, "AmazonRegion": "us-east-1", - "AmazonLogBucket": "smapi-log-parser", + "AmazonTempBucket": "smapi-web-temp", "ChucklefishBaseUrl": "https://community.playstarbound.com", "ChucklefishModPageUrlFormat": "resources/{0}", diff --git a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css index cd117694..18195098 100644 --- a/src/SMAPI.Web/wwwroot/Content/css/json-validator.css +++ b/src/SMAPI.Web/wwwroot/Content/css/json-validator.css @@ -41,6 +41,12 @@ background: #FCC; } +.save-metadata { + margin-top: 1em; + font-size: 0.8em; + opacity: 0.3; +} + /********* ** Validation results *********/ -- cgit From 8ddb60cee636cc17291100c316df4786eb3bb448 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 3 Dec 2019 23:06:42 -0500 Subject: move supporter list into environment config --- src/SMAPI.Web/Controllers/IndexController.cs | 2 +- src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs | 3 +++ src/SMAPI.Web/ViewModels/IndexModel.cs | 7 ++++++- src/SMAPI.Web/Views/Index/Index.cshtml | 24 ++++++++-------------- src/SMAPI.Web/appsettings.Development.json | 5 ----- src/SMAPI.Web/appsettings.json | 5 +++-- 6 files changed, 22 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Controllers/IndexController.cs b/src/SMAPI.Web/Controllers/IndexController.cs index 4e3602d5..a887f14a 100644 --- a/src/SMAPI.Web/Controllers/IndexController.cs +++ b/src/SMAPI.Web/Controllers/IndexController.cs @@ -72,7 +72,7 @@ namespace StardewModdingAPI.Web.Controllers : null; // render view - var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb); + var model = new IndexModel(stableVersionModel, betaVersionModel, this.SiteConfig.BetaBlurb, this.SiteConfig.SupporterList); return this.View(model); } diff --git a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs index d379c423..43969f51 100644 --- a/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs +++ b/src/SMAPI.Web/Framework/ConfigModels/SiteConfig.cs @@ -11,5 +11,8 @@ namespace StardewModdingAPI.Web.Framework.ConfigModels /// A short sentence shown under the beta download button, if any. public string BetaBlurb { get; set; } + + /// A list of supports to credit on the main page, in Markdown format. + public string SupporterList { get; set; } } } diff --git a/src/SMAPI.Web/ViewModels/IndexModel.cs b/src/SMAPI.Web/ViewModels/IndexModel.cs index 82c4e06f..450fdc0e 100644 --- a/src/SMAPI.Web/ViewModels/IndexModel.cs +++ b/src/SMAPI.Web/ViewModels/IndexModel.cs @@ -15,6 +15,9 @@ namespace StardewModdingAPI.Web.ViewModels /// A short sentence shown under the beta download button, if any. public string BetaBlurb { get; set; } + /// A list of supports to credit on the main page, in Markdown format. + public string SupporterList { get; set; } + /********* ** Public methods @@ -26,11 +29,13 @@ namespace StardewModdingAPI.Web.ViewModels /// The latest stable SMAPI version. /// The latest prerelease SMAPI version (if newer than ). /// A short sentence shown under the beta download button, if any. - internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb) + /// A list of supports to credit on the main page, in Markdown format. + internal IndexModel(IndexVersionModel stableVersion, IndexVersionModel betaVersion, string betaBlurb, string supporterList) { this.StableVersion = stableVersion; this.BetaVersion = betaVersion; this.BetaBlurb = betaBlurb; + this.SupporterList = supporterList; } } } diff --git a/src/SMAPI.Web/Views/Index/Index.cshtml b/src/SMAPI.Web/Views/Index/Index.cshtml index ec9cfafe..5d91dc84 100644 --- a/src/SMAPI.Web/Views/Index/Index.cshtml +++ b/src/SMAPI.Web/Views/Index/Index.cshtml @@ -1,3 +1,4 @@ +@using Markdig @using Microsoft.Extensions.Options @using StardewModdingAPI.Web.Framework @using StardewModdingAPI.Web.Framework.ConfigModels @@ -94,29 +95,22 @@ else
  • -

    - Special thanks to - bwdy, - hawkfalcon, - iKeychain, - jwdred, - Karmylla, - minervamaga, - Pucklynn, - Renorien, - Robby LaFarge, - and a few anonymous users for their ongoing support on Patreon; you're awesome! -

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

    For mod creators

      diff --git a/src/SMAPI.Web/appsettings.Development.json b/src/SMAPI.Web/appsettings.Development.json index 6b32f4ab..74ded25d 100644 --- a/src/SMAPI.Web/appsettings.Development.json +++ b/src/SMAPI.Web/appsettings.Development.json @@ -8,11 +8,6 @@ */ { - "Site": { - "BetaEnabled": false, - "BetaBlurb": null - }, - "ApiClients": { "AmazonAccessKey": null, "AmazonSecretKey": null, diff --git a/src/SMAPI.Web/appsettings.json b/src/SMAPI.Web/appsettings.json index b3567469..2e20b299 100644 --- a/src/SMAPI.Web/appsettings.json +++ b/src/SMAPI.Web/appsettings.json @@ -16,8 +16,9 @@ }, "Site": { - "BetaEnabled": null, - "BetaBlurb": null + "BetaEnabled": false, + "BetaBlurb": null, + "SupporterList": null }, "ApiClients": { -- cgit From 9465628effad6bdb1994599031a8f60c3af2452e Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Wed, 4 Dec 2019 20:52:40 -0500 Subject: fix JSON validator format selector no longer working since URL changes --- src/SMAPI.Web/Views/JsonValidator/Index.cshtml | 6 +++--- src/SMAPI.Web/wwwroot/Content/js/json-validator.js | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml index a5a134ac..a042f024 100644 --- a/src/SMAPI.Web/Views/JsonValidator/Index.cshtml +++ b/src/SMAPI.Web/Views/JsonValidator/Index.cshtml @@ -27,17 +27,17 @@ { } - + - + } diff --git a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js index 76b5f6d4..401efbee 100644 --- a/src/SMAPI.Web/wwwroot/Content/js/json-validator.js +++ b/src/SMAPI.Web/wwwroot/Content/js/json-validator.js @@ -70,10 +70,10 @@ smapi.LineNumberRange = function (maxLines) { /** * UI logic for the JSON validator page. - * @param {any} sectionUrl The base JSON validator page URL. - * @param {any} pasteID The Pastebin paste ID for the content being viewed, if any. + * @param {string} urlFormat The URL format for a file, with $schemaName and $id placeholders. + * @param {string} pasteID The Pastebin paste ID for the content being viewed, if any. */ -smapi.jsonValidator = function (sectionUrl, pasteID) { +smapi.jsonValidator = function (urlFormat, pasteID) { /** * 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", pasteID); }); // upload form -- cgit From 9c9a0a41b041a1799904e78596fdf1d77451e1c4 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Thu, 5 Dec 2019 22:10:57 -0500 Subject: update for 'force off' gamepad option added in Stardew Valley 1.4.0.1 --- src/SMAPI/Framework/Input/SInputState.cs | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/SMAPI/Framework/Input/SInputState.cs b/src/SMAPI/Framework/Input/SInputState.cs index d69e5604..84cea36c 100644 --- a/src/SMAPI/Framework/Input/SInputState.cs +++ b/src/SMAPI/Framework/Input/SInputState.cs @@ -129,6 +129,9 @@ namespace StardewModdingAPI.Framework.Input [Obsolete("This method should only be called by the game itself.")] public override GamePadState GetGamePadState() { + if (Game1.options.gamepadMode == Options.GamepadModes.ForceOff) + return base.GetGamePadState(); + return this.ShouldSuppressNow() ? this.SuppressedController : this.RealController; -- cgit From 47beb2f5345670be5fc1ba5ec109835f6a67e7a0 Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sat, 7 Dec 2019 19:24:27 -0500 Subject: fix launcher compatibility on Arch Linux Arch Linux sets the $TERMINAL variable, which makes SMAPI think the terminal is being overridden for testing and bypass the terminal selection logic. Since it's only used for testing, we can re-add it locally when needed. --- src/SMAPI.Installer/unix-launcher.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/SMAPI.Installer/unix-launcher.sh b/src/SMAPI.Installer/unix-launcher.sh index bebba9fe..1422d888 100644 --- a/src/SMAPI.Installer/unix-launcher.sh +++ b/src/SMAPI.Installer/unix-launcher.sh @@ -61,8 +61,8 @@ else COMMAND="type" fi - # select terminal (prefer $TERMINAL for overrides and testing, then xterm for best compatibility, then known supported terminals) - for terminal in "$TERMINAL" xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty x-terminal-emulator; do + # select terminal (prefer xterm for best compatibility, then known supported terminals) + for terminal in xterm gnome-terminal kitty terminator xfce4-terminal konsole terminal termite alacritty 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 -- cgit From 04b9a810dde93ff790e356f0af3510c7d20bebfc Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Dec 2019 11:27:23 -0500 Subject: add asset propagation for grass textures --- src/SMAPI/Metadata/CoreAssetPropagator.cs | 35 ++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 1c0a04f0..985e4e1b 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -474,10 +474,14 @@ namespace StardewModdingAPI.Metadata /**** ** Content\TerrainFeatures ****/ - case "terrainfeatures\\flooring": // Flooring + case "terrainfeatures\\flooring": // from Flooring Flooring.floorsTexture = content.Load(key); return true; + case "terrainfeatures\\grass": // from Grass + this.ReloadGrassTextures(content, key); + return true; + case "terrainfeatures\\hoedirt": // from HoeDirt HoeDirt.lightTexture = content.Load(key); return true; @@ -694,6 +698,35 @@ namespace StardewModdingAPI.Metadata return true; } + /// Reload tree textures. + /// The content manager through which to reload the asset. + /// The asset key to reload. + /// Returns whether any textures were reloaded. + private bool ReloadGrassTextures(LocalizedContentManager content, string key) + { + Grass[] grasses = + ( + from location in Game1.locations + from grass in location.terrainFeatures.Values.OfType() + let textureName = this.NormalizeAssetNameIgnoringEmpty( + this.Reflection.GetMethod(grass, "textureName").Invoke() + ) + where textureName == key + select grass + ) + .ToArray(); + + if (grasses.Any()) + { + Lazy texture = new Lazy(() => content.Load(key)); + foreach (Grass grass in grasses) + this.Reflection.GetField>(grass, "texture").SetValue(texture); + return true; + } + + return false; + } + /// Reload the disposition data for matching NPCs. /// The content manager through which to reload the asset. /// The asset key to reload. -- cgit From 194b96a79c335fa098a6cf55c2be75c7f2e9c6ad Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Dec 2019 11:31:20 -0500 Subject: use GetLocations logic more consistently in asset propagation --- src/SMAPI/Metadata/CoreAssetPropagator.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Metadata/CoreAssetPropagator.cs b/src/SMAPI/Metadata/CoreAssetPropagator.cs index 985e4e1b..8b00d893 100644 --- a/src/SMAPI/Metadata/CoreAssetPropagator.cs +++ b/src/SMAPI/Metadata/CoreAssetPropagator.cs @@ -611,7 +611,7 @@ namespace StardewModdingAPI.Metadata { // get buildings string type = Path.GetFileName(key); - Building[] buildings = Game1.locations + Building[] buildings = this.GetLocations(buildingInteriors: false) .OfType() .SelectMany(p => p.buildings) .Where(p => p.buildingType.Value == type) @@ -706,7 +706,7 @@ namespace StardewModdingAPI.Metadata { Grass[] grasses = ( - from location in Game1.locations + from location in this.GetLocations() from grass in location.terrainFeatures.Values.OfType() let textureName = this.NormalizeAssetNameIgnoringEmpty( this.Reflection.GetMethod(grass, "textureName").Invoke() @@ -804,7 +804,7 @@ namespace StardewModdingAPI.Metadata /// Returns whether any textures were reloaded. private bool ReloadTreeTextures(LocalizedContentManager content, string key, int type) { - Tree[] trees = Game1.locations + Tree[] trees = this.GetLocations() .SelectMany(p => p.terrainFeatures.Values.OfType()) .Where(tree => tree.treeType.Value == type) .ToArray(); @@ -909,7 +909,8 @@ namespace StardewModdingAPI.Metadata } /// Get all locations in the game. - private IEnumerable GetLocations() + /// Whether to also get the interior locations for constructable buildings. + private IEnumerable GetLocations(bool buildingInteriors = true) { // get available root locations IEnumerable rootLocations = Game1.locations; @@ -921,7 +922,7 @@ namespace StardewModdingAPI.Metadata { yield return location; - if (location is BuildableGameLocation buildableLocation) + if (buildingInteriors && location is BuildableGameLocation buildableLocation) { foreach (Building building in buildableLocation.buildings) { -- cgit From 238fbfe5698fb1791d47e8772ba1c5a86f9300ca Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Sun, 8 Dec 2019 12:20:59 -0500 Subject: let mods use Read/WriteSaveData while a save is being loaded --- src/SMAPI/Framework/ModHelpers/DataHelper.cs | 48 +++++++++++++++++++++------- 1 file changed, 37 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/SMAPI/Framework/ModHelpers/DataHelper.cs b/src/SMAPI/Framework/ModHelpers/DataHelper.cs index cc08c42b..3d43c539 100644 --- a/src/SMAPI/Framework/ModHelpers/DataHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/DataHelper.cs @@ -1,6 +1,8 @@ using System; +using System.Collections.Generic; using System.IO; using Newtonsoft.Json; +using StardewModdingAPI.Enums; using StardewModdingAPI.Toolkit.Serialization; using StardewModdingAPI.Toolkit.Utilities; using StardewValley; @@ -77,33 +79,45 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The player hasn't loaded a save file yet or isn't the main player. public TModel ReadSaveData(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(value) - : null; + + string internalKey = this.GetSaveFileKey(key); + foreach (IDictionary dataField in this.GetDataFields(Context.LoadStage)) + { + if (dataField.TryGetValue(internalKey, out string value)) + return this.JsonHelper.Deserialize(value); + } + return null; } /// 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. /// The model type. This should be a plain class that has public properties for the data you want. The properties can be complex types. /// The unique key identifying the data. - /// The arbitrary data to save. + /// The arbitrary data to save. /// The player hasn't loaded a save file yet or isn't the main player. - public void WriteSaveData(string key, TModel data) where TModel : class + public void WriteSaveData(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.GetSave